From 03074752bb0906a30861db3e095d8d2da62a3414 Mon Sep 17 00:00:00 2001 From: Joaquim Verges Date: Mon, 29 Sep 2025 15:32:07 +1300 Subject: [PATCH 1/3] Improve token info discovery for x402 payments --- .changeset/dirty-experts-kiss.md | 5 + .../x402/components/X402LeftSection.tsx | 153 +++++++++ .../x402/components/X402Playground.tsx | 44 +++ .../x402/components/X402RightSection.tsx | 305 ++++++++++++++++++ .../app/payments/x402/components/constants.ts | 12 +- .../src/app/payments/x402/components/types.ts | 11 + .../x402/components/x402-client-preview.tsx | 120 ------- .../src/app/payments/x402/page.tsx | 112 +------ apps/playground-web/src/middleware.ts | 29 +- packages/thirdweb/src/x402/common.ts | 94 +++++- packages/thirdweb/src/x402/facilitator.ts | 33 +- .../thirdweb/src/x402/fetchWithPayment.ts | 12 +- packages/thirdweb/src/x402/schemas.ts | 39 +++ packages/thirdweb/src/x402/types.ts | 13 +- 14 files changed, 711 insertions(+), 271 deletions(-) create mode 100644 .changeset/dirty-experts-kiss.md create mode 100644 apps/playground-web/src/app/payments/x402/components/X402LeftSection.tsx create mode 100644 apps/playground-web/src/app/payments/x402/components/X402Playground.tsx create mode 100644 apps/playground-web/src/app/payments/x402/components/X402RightSection.tsx create mode 100644 apps/playground-web/src/app/payments/x402/components/types.ts delete mode 100644 apps/playground-web/src/app/payments/x402/components/x402-client-preview.tsx diff --git a/.changeset/dirty-experts-kiss.md b/.changeset/dirty-experts-kiss.md new file mode 100644 index 00000000000..16e66b28bc6 --- /dev/null +++ b/.changeset/dirty-experts-kiss.md @@ -0,0 +1,5 @@ +--- +"thirdweb": patch +--- + +Improve token info discovery for x402 payments diff --git a/apps/playground-web/src/app/payments/x402/components/X402LeftSection.tsx b/apps/playground-web/src/app/payments/x402/components/X402LeftSection.tsx new file mode 100644 index 00000000000..de325817838 --- /dev/null +++ b/apps/playground-web/src/app/payments/x402/components/X402LeftSection.tsx @@ -0,0 +1,153 @@ +"use client"; + +import type React from "react"; +import { useId, useState } from "react"; +import { defineChain } from "thirdweb/chains"; +import { BridgeNetworkSelector } from "@/components/blocks/NetworkSelectors"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { TokenSelector } from "@/components/ui/TokenSelector"; +import { THIRDWEB_CLIENT } from "@/lib/client"; +import type { TokenMetadata } from "@/lib/types"; +import type { X402PlaygroundOptions } from "./types"; + +export function X402LeftSection(props: { + options: X402PlaygroundOptions; + setOptions: React.Dispatch>; +}) { + const { options, setOptions } = props; + + // Local state for chain and token selection + const [selectedChain, setSelectedChain] = useState(() => { + return options.chain?.id; + }); + + const [selectedToken, setSelectedToken] = useState< + { chainId: number; address: string } | undefined + >(() => { + if (options.tokenAddress && options.chain?.id) { + return { + address: options.tokenAddress, + chainId: options.chain.id, + }; + } + return undefined; + }); + + const chainId = useId(); + const tokenId = useId(); + const amountId = useId(); + const payToId = useId(); + + const handleChainChange = (chainId: number) => { + setSelectedChain(chainId); + // Clear token selection when chain changes + setSelectedToken(undefined); + + setOptions((v) => ({ + ...v, + chain: defineChain(chainId), + tokenAddress: "0x0000000000000000000000000000000000000000" as const, + tokenSymbol: "", + tokenDecimals: 18, + })); + }; + + const handleTokenChange = (token: TokenMetadata) => { + setSelectedToken({ + address: token.address, + chainId: selectedChain!, + }); + + setOptions((v) => ({ + ...v, + tokenAddress: token.address as `0x${string}`, + tokenSymbol: token.symbol ?? "", + tokenDecimals: token.decimals ?? 18, + })); + }; + + const handleAmountChange = (e: React.ChangeEvent) => { + setOptions((v) => ({ + ...v, + amount: e.target.value, + })); + }; + + const handlePayToChange = (e: React.ChangeEvent) => { + setOptions((v) => ({ + ...v, + payTo: e.target.value as `0x${string}`, + })); + }; + + return ( +
+
+

Configuration

+
+ {/* Chain selection */} +
+ + +
+ + {/* Token selection - only show if chain is selected */} + {selectedChain && ( +
+ + +
+ )} + + {/* Amount input */} +
+ + + {options.tokenSymbol && ( +

+ Amount in {options.tokenSymbol} +

+ )} +
+ + {/* Pay To input */} +
+ + +

+ The wallet address that will receive the payment +

+
+
+
+
+ ); +} diff --git a/apps/playground-web/src/app/payments/x402/components/X402Playground.tsx b/apps/playground-web/src/app/payments/x402/components/X402Playground.tsx new file mode 100644 index 00000000000..ccc035b11ec --- /dev/null +++ b/apps/playground-web/src/app/payments/x402/components/X402Playground.tsx @@ -0,0 +1,44 @@ +"use client"; + +import React, { useState } from "react"; +import { useActiveAccount } from "thirdweb/react"; +import { chain, token } from "./constants"; +import type { X402PlaygroundOptions } from "./types"; +import { X402LeftSection } from "./X402LeftSection"; +import { X402RightSection } from "./X402RightSection"; + +const defaultOptions: X402PlaygroundOptions = { + chain: chain, + tokenAddress: token.address as `0x${string}`, + tokenSymbol: token.symbol, + tokenDecimals: token.decimals, + amount: "0.01", + payTo: "0x0000000000000000000000000000000000000000", +}; + +export function X402Playground() { + const [options, setOptions] = useState(defaultOptions); + const activeAccount = useActiveAccount(); + + // Update payTo address when wallet connects, but only if it's still the default + React.useEffect(() => { + if ( + activeAccount?.address && + options.payTo === "0x0000000000000000000000000000000000000000" + ) { + setOptions((prev) => ({ + ...prev, + payTo: activeAccount.address as `0x${string}`, + })); + } + }, [activeAccount?.address, options.payTo]); + + return ( +
+
+ +
+ +
+ ); +} diff --git a/apps/playground-web/src/app/payments/x402/components/X402RightSection.tsx b/apps/playground-web/src/app/payments/x402/components/X402RightSection.tsx new file mode 100644 index 00000000000..77dbebd7eae --- /dev/null +++ b/apps/playground-web/src/app/payments/x402/components/X402RightSection.tsx @@ -0,0 +1,305 @@ +"use client"; + +import { useMutation } from "@tanstack/react-query"; +import { Badge } from "@workspace/ui/components/badge"; +import { CodeClient } from "@workspace/ui/components/code/code.client"; +import { CodeIcon, LockIcon } from "lucide-react"; +import { usePathname } from "next/navigation"; +import { useState } from "react"; +import { + ConnectButton, + 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"; +import { cn } from "@/lib/utils"; +import type { X402PlaygroundOptions } from "./types"; + +type Tab = "ui" | "client-code" | "server-code"; + +export function X402RightSection(props: { options: X402PlaygroundOptions }) { + const pathname = usePathname(); + const [previewTab, _setPreviewTab] = useState(() => { + return "ui"; + }); + + function setPreviewTab(tab: "ui" | "client-code" | "server-code") { + _setPreviewTab(tab); + window.history.replaceState({}, "", `${pathname}?tab=${tab}`); + } + + 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, + BigInt(1 * 10 ** 18), + ); + const searchParams = new URLSearchParams(); + searchParams.set("chainId", props.options.chain.id.toString()); + searchParams.set("payTo", props.options.payTo); + searchParams.set("amount", props.options.amount); + searchParams.set("tokenAddress", props.options.tokenAddress); + searchParams.set("decimals", props.options.tokenDecimals.toString()); + + const url = + "/api/paywall" + + (searchParams.size > 0 ? "?" + searchParams.toString() : ""); + const response = await fetchWithPay(url.toString()); + return response.json(); + }, + }); + + const handlePayClick = async () => { + paidApiCall.mutate(); + }; + + const clientCode = `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 ( + + ); +}`; + + const serverCode = `// Usage in a Next.js API route +import { settlePayment, facilitator } from "thirdweb/x402"; +import { createThirdwebClient } from "thirdweb"; +import { defineChain } from "thirdweb/chains"; + +const client = createThirdwebClient({ + secretKey: process.env.THIRDWEB_SECRET_KEY, +}); + +const thirdwebFacilitator = facilitator({ + client, + serverWalletAddress: "0xYourServerWalletAddress", +}); + +export async function POST(request: Request) { + const paymentData = request.headers.get("x-payment"); + + // verify and process the payment + const result = await settlePayment({ + resourceUrl: "https://api.example.com/premium-content", + method: "POST", + paymentData, + payTo: "${props.options.payTo}", + network: defineChain(${props.options.chain.id}), + price: { + amount: "${Number(props.options.amount) * 10 ** props.options.tokenDecimals}", + asset: { + address: "${props.options.tokenAddress}", + }, + }, + facilitator: thirdwebFacilitator, + }); + + if (result.status === 200) { + // Payment verified and settled successfully + return Response.json({ data: "premium content" }); + } else { + // Payment required + return Response.json(result.responseBody, { + status: result.status, + headers: result.responseHeaders, + }); + } +}`; + + return ( +
+ setPreviewTab("ui"), + }, + { + isActive: previewTab === "client-code", + name: "Client Code", + onClick: () => setPreviewTab("client-code"), + }, + { + isActive: previewTab === "server-code", + name: "Server Code", + onClick: () => setPreviewTab("server-code"), + }, + ]} + /> + +
+ + + {previewTab === "ui" && ( +
+ + +
+ + Paid API Call + + + {props.options.amount}{" "} + {props.options.tokenSymbol || "tokens"} + + +
+ + +

+ Pay for access with {props.options.tokenSymbol || "tokens"} on{" "} + {props.options.chain.name || `chain ${props.options.chain.id}`} +

+
+ +
+ + API Call Response +
+ {paidApiCall.isPending && ( +
Loading...
+ )} + {paidApiCall.isError && ( +
+ Error: {paidApiCall.error.message} +
+ )} + {paidApiCall.data && ( + + )} +
+
+ )} + + {previewTab === "client-code" && ( +
+ +
+ )} + + {previewTab === "server-code" && ( +
+ +
+ )} +
+
+ ); +} + +function BackgroundPattern() { + const color = "hsl(var(--foreground)/15%)"; + return ( +
+ ); +} + +function TabButtons(props: { + tabs: Array<{ + name: string; + isActive: boolean; + onClick: () => void; + }>; +}) { + return ( +
+
+ {props.tabs.map((tab) => ( + + ))} +
+
+ ); +} diff --git a/apps/playground-web/src/app/payments/x402/components/constants.ts b/apps/playground-web/src/app/payments/x402/components/constants.ts index 4e158d63dcd..d463fbd2342 100644 --- a/apps/playground-web/src/app/payments/x402/components/constants.ts +++ b/apps/playground-web/src/app/payments/x402/components/constants.ts @@ -1,10 +1,14 @@ // const chain = arbitrumSepolia; -import { arbitrumSepolia } from "thirdweb/chains"; -import { getDefaultToken } from "thirdweb/react"; +import { base } from "thirdweb/chains"; -export const chain = arbitrumSepolia; -export const token = getDefaultToken(chain, "USDC")!; +export const chain = base; +export const token = { + address: "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913", + name: "USD Coin", + symbol: "USDC", + decimals: 6, +}; // export const chain = base; // export const token = { // address: "0x0578d8A44db98B23BF096A382e016e29a5Ce0ffe", diff --git a/apps/playground-web/src/app/payments/x402/components/types.ts b/apps/playground-web/src/app/payments/x402/components/types.ts new file mode 100644 index 00000000000..879cd33a4e7 --- /dev/null +++ b/apps/playground-web/src/app/payments/x402/components/types.ts @@ -0,0 +1,11 @@ +import type { Chain } from "thirdweb/chains"; +import type { Address } from "thirdweb/utils"; + +export type X402PlaygroundOptions = { + chain: Chain; + tokenAddress: Address; + tokenSymbol: string; + tokenDecimals: number; + amount: string; + payTo: Address; +}; diff --git a/apps/playground-web/src/app/payments/x402/components/x402-client-preview.tsx b/apps/playground-web/src/app/payments/x402/components/x402-client-preview.tsx deleted file mode 100644 index 7994e90e81f..00000000000 --- a/apps/playground-web/src/app/payments/x402/components/x402-client-preview.tsx +++ /dev/null @@ -1,120 +0,0 @@ -"use client"; - -import { useMutation } from "@tanstack/react-query"; -import { Badge } from "@workspace/ui/components/badge"; -import { CodeClient } from "@workspace/ui/components/code/code.client"; -import { CodeIcon, LockIcon } from "lucide-react"; -import { - ConnectButton, - 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"; -import { chain, token } from "./constants"; - -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, - BigInt(1 * 10 ** 18), - ); - const searchParams = new URLSearchParams(); - searchParams.set("chainId", chain.id.toString()); - searchParams.set("payTo", activeWallet.getAccount()?.address || ""); - // TODO (402): dynamic from playground config - // if (token) { - // searchParams.set("amount", "0.01"); - // searchParams.set("tokenAddress", token.address); - // searchParams.set("decimals", token.decimals.toString()); - // } - const url = - "/api/paywall" + - (searchParams.size > 0 ? "?" + searchParams.toString() : ""); - const response = await fetchWithPay(url.toString()); - return response.json(); - }, - }); - - const handlePayClick = async () => { - paidApiCall.mutate(); - }; - - return ( -
- - -
- - Paid API Call - - 0.1 {token.symbol} - -
- - -

- Pay for access with {token.symbol} on{" "} - {chain.name || `chain ${chain.id}`} -

- {chain.testnet && token.symbol.toLowerCase() === "usdc" && ( -

- {" "} - - Click here to get testnet {token.symbol} on {chain.name} - -

- )} -
- -
- - API Call Response -
- {paidApiCall.isPending &&
Loading...
} - {paidApiCall.isError && ( -
Error: {paidApiCall.error.message}
- )} - {paidApiCall.data && ( - - )} -
-
- ); -} diff --git a/apps/playground-web/src/app/payments/x402/page.tsx b/apps/playground-web/src/app/payments/x402/page.tsx index 3481ccacbfb..5227d1fdcde 100644 --- a/apps/playground-web/src/app/payments/x402/page.tsx +++ b/apps/playground-web/src/app/payments/x402/page.tsx @@ -1,10 +1,8 @@ -import { CodeServer } from "@workspace/ui/components/code/code.server"; -import { CircleDollarSignIcon, Code2Icon } from "lucide-react"; -import { CodeExample, TabName } from "@/components/code/code-example"; +import { CircleDollarSignIcon } from "lucide-react"; import ThirdwebProvider from "@/components/thirdweb-provider"; import { PageLayout } from "../../../components/blocks/APIHeader"; import { createMetadata } from "../../../lib/metadata"; -import { X402ClientPreview } from "./components/x402-client-preview"; +import { X402Playground } from "./components/X402Playground"; const title = "x402 Payments"; const description = @@ -30,112 +28,8 @@ export default function Page() { description={description} docsLink="https://portal.thirdweb.com/payments/x402?utm_source=playground" > - -
- + ); } - -function ServerCodeExample() { - return ( - <> -
-

- Next.js Server Code Example -

-

- Create middleware with the thirdweb facilitator to settle transactions - with your server wallet. -

-
-
-
- - -
-
- - ); -} - -function X402Example() { - return ( - { - const fetchWithPay = wrapFetchWithPayment(fetch, client, wallet); - const response = await fetchWithPay('/api/paid-endpoint'); - } - - return ( - - ); -}`} - lang="tsx" - preview={} - /> - ); -} diff --git a/apps/playground-web/src/middleware.ts b/apps/playground-web/src/middleware.ts index 25d7e5bdb65..e6963058a39 100644 --- a/apps/playground-web/src/middleware.ts +++ b/apps/playground-web/src/middleware.ts @@ -1,6 +1,8 @@ import { type NextRequest, NextResponse } from "next/server"; import { createThirdwebClient, defineChain } from "thirdweb"; +import { toUnits } from "thirdweb/utils"; import { facilitator, settlePayment } from "thirdweb/x402"; +import { token } from "./app/payments/x402/components/constants"; const client = createThirdwebClient({ secretKey: process.env.THIRDWEB_SECRET_KEY as string, @@ -16,6 +18,7 @@ const twFacilitator = facilitator({ client, serverWalletAddress: BACKEND_WALLET_ADDRESS, vaultAccessToken: ENGINE_VAULT_ACCESS_TOKEN, + waitUtil: "simulated", }); export async function middleware(request: NextRequest) { @@ -35,10 +38,9 @@ export async function middleware(request: NextRequest) { ); } - // TODO (402): dynamic from playground config - // const amount = queryParams.get("amount"); - // const tokenAddress = queryParams.get("tokenAddress"); - // const decimals = queryParams.get("decimals"); + const amount = queryParams.get("amount") || "0.01"; + const tokenAddress = queryParams.get("tokenAddress") || token.address; + const decimals = queryParams.get("decimals") || token.decimals.toString(); const result = await settlePayment({ resourceUrl, @@ -46,18 +48,13 @@ export async function middleware(request: NextRequest) { paymentData, payTo: payTo as `0x${string}`, network: defineChain(Number(chainId)), - price: "$0.01", - // price: { - // amount: toUnits(amount as string, parseInt(decimals as string)).toString(), - // asset: { - // address: tokenAddress as `0x${string}`, - // decimals: decimals ? parseInt(decimals) : token.decimals, - // eip712: { - // name: token.name, - // version: token.version, - // }, - // }, - // }, + price: { + amount: toUnits(amount, parseInt(decimals)).toString(), + asset: { + address: tokenAddress as `0x${string}`, + decimals: decimals ? parseInt(decimals) : token.decimals, + }, + }, routeConfig: { description: "Access to paid content", }, diff --git a/packages/thirdweb/src/x402/common.ts b/packages/thirdweb/src/x402/common.ts index b4eb3b834d7..86b63526557 100644 --- a/packages/thirdweb/src/x402/common.ts +++ b/packages/thirdweb/src/x402/common.ts @@ -16,6 +16,7 @@ import { type RequestedPaymentRequirements, } from "./schemas.js"; import { + type DefaultAsset, type ERC20TokenAmount, type PaymentArgs, type PaymentRequiredResult, @@ -199,12 +200,11 @@ async function processPriceToAtomicAmount( chainId: number, facilitator: ThirdwebX402Facilitator, ): Promise< - | { maxAmountRequired: string; asset: ERC20TokenAmount["asset"] } - | { error: string } + { maxAmountRequired: string; asset: DefaultAsset } | { error: string } > { // Handle USDC amount (string) or token amount (ERC20TokenAmount) let maxAmountRequired: string; - let asset: ERC20TokenAmount["asset"]; + let asset: DefaultAsset; if (typeof price === "string" || typeof price === "number") { // USDC amount in dollars @@ -222,11 +222,32 @@ async function processPriceToAtomicAmount( }; } asset = defaultAsset; - maxAmountRequired = (parsedUsdAmount * 10 ** asset.decimals).toString(); + maxAmountRequired = ( + parsedUsdAmount * + 10 ** defaultAsset.decimals + ).toString(); } else { // Token amount in atomic units maxAmountRequired = price.amount; - asset = price.asset; + const tokenExtras = await getOrDetectTokenExtras({ + facilitator, + partialAsset: price.asset, + chainId, + }); + if (!tokenExtras) { + return { + error: `Unable to find token information for ${price.asset.address} on chain ${chainId}. Please specify the asset decimals and eip712 information in the asset options.`, + }; + } + asset = { + address: price.asset.address, + decimals: tokenExtras.decimals, + eip712: { + name: tokenExtras.name, + version: tokenExtras.version, + primaryType: tokenExtras.primaryType, + }, + }; } return { @@ -238,13 +259,12 @@ async function processPriceToAtomicAmount( async function getDefaultAsset( chainId: number, facilitator: ThirdwebX402Facilitator, -): Promise { +): Promise { const supportedAssets = await facilitator.supported(); const matchingAsset = supportedAssets.kinds.find( (supported) => supported.network === `eip155:${chainId}`, ); - const assetConfig = matchingAsset?.extra - ?.defaultAsset as ERC20TokenAmount["asset"]; + const assetConfig = matchingAsset?.extra?.defaultAsset as DefaultAsset; return assetConfig; } @@ -287,3 +307,61 @@ export async function getSupportedSignatureType(args: { } return undefined; } + +async function getOrDetectTokenExtras(args: { + facilitator: ThirdwebX402Facilitator; + partialAsset: ERC20TokenAmount["asset"]; + chainId: number; +}): Promise< + | { + name: string; + version: string; + decimals: number; + primaryType: SupportedSignatureType; + } + | undefined +> { + const { facilitator, partialAsset, chainId } = args; + if ( + partialAsset.eip712?.name && + partialAsset.eip712?.version && + partialAsset.decimals + ) { + return { + name: partialAsset.eip712.name, + version: partialAsset.eip712.version, + decimals: partialAsset.decimals, + primaryType: partialAsset.eip712.primaryType, + }; + } + // read from facilitator + const response = await facilitator + .supported({ + chainId, + tokenAddress: partialAsset.address, + }) + .catch(() => { + return { + kinds: [], + }; + }); + + const exactScheme = response.kinds?.find((kind) => kind.scheme === "exact"); + if (!exactScheme) { + return undefined; + } + const supportedAsset = exactScheme.extra?.supportedAssets?.find( + (asset) => + asset.address.toLowerCase() === partialAsset.address.toLowerCase(), + ); + if (!supportedAsset) { + return undefined; + } + + return { + name: supportedAsset.eip712.name, + version: supportedAsset.eip712.version, + decimals: supportedAsset.decimals, + primaryType: supportedAsset.eip712.primaryType as SupportedSignatureType, + }; +} diff --git a/packages/thirdweb/src/x402/facilitator.ts b/packages/thirdweb/src/x402/facilitator.ts index b9047e162f8..b81faa3d1a7 100644 --- a/packages/thirdweb/src/x402/facilitator.ts +++ b/packages/thirdweb/src/x402/facilitator.ts @@ -1,9 +1,10 @@ -import type { SupportedPaymentKindsResponse, VerifyResponse } from "x402/types"; +import type { VerifyResponse } from "x402/types"; import type { ThirdwebClient } from "../client/client.js"; import { stringify } from "../utils/json.js"; import { withCache } from "../utils/promise/withCache.js"; import type { FacilitatorSettleResponse, + FacilitatorSupportedResponse, RequestedPaymentPayload, RequestedPaymentRequirements, } from "./schemas.js"; @@ -11,6 +12,7 @@ import type { export type ThirdwebX402FacilitatorConfig = { client: ThirdwebClient; serverWalletAddress: string; + waitUtil?: "simulated" | "submitted" | "confirmed"; vaultAccessToken?: string; baseUrl?: string; }; @@ -36,7 +38,10 @@ export type ThirdwebX402Facilitator = { payload: RequestedPaymentPayload, paymentRequirements: RequestedPaymentRequirements, ) => Promise; - supported: () => Promise; + supported: (filters?: { + chainId: number; + tokenAddress?: string; + }) => Promise; }; const DEFAULT_BASE_URL = "https://api.thirdweb.com/v1/payments/x402"; @@ -176,6 +181,7 @@ export function facilitator( x402Version: payload.x402Version, paymentPayload: payload, paymentRequirements: paymentRequirements, + ...(config.waitUtil ? { waitUtil: config.waitUtil } : {}), }), }); @@ -193,14 +199,27 @@ export function facilitator( * * @returns A promise that resolves to the supported payment kinds */ - async supported(): Promise { + async supported(filters?: { + chainId: number; + tokenAddress?: string; + }): Promise { const url = config.baseUrl ?? DEFAULT_BASE_URL; return withCache( async () => { let headers = { "Content-Type": "application/json" }; const authHeaders = await facilitator.createAuthHeaders(); headers = { ...headers, ...authHeaders.supported }; - const res = await fetch(`${url}/supported`, { headers }); + const supportedUrl = new URL(`${url}/supported`); + if (filters?.chainId) { + supportedUrl.searchParams.set( + "chainId", + filters.chainId.toString(), + ); + } + if (filters?.tokenAddress) { + supportedUrl.searchParams.set("tokenAddress", filters.tokenAddress); + } + const res = await fetch(supportedUrl.toString(), { headers }); if (res.status !== 200) { throw new Error( @@ -209,11 +228,11 @@ export function facilitator( } const data = await res.json(); - return data as SupportedPaymentKindsResponse; + return data as FacilitatorSupportedResponse; }, { - cacheKey: `supported-payment-kinds-${url}`, - cacheTime: 1000 * 60 * 60 * 24, // 24 hours + cacheKey: `supported-payment-kinds-${url}-${filters?.chainId}-${filters?.tokenAddress}2`, + cacheTime: 1000 * 60 * 60 * 1, // 1 hour }, ); }, diff --git a/packages/thirdweb/src/x402/fetchWithPayment.ts b/packages/thirdweb/src/x402/fetchWithPayment.ts index 6c0b2bc2ab7..19d3dab57da 100644 --- a/packages/thirdweb/src/x402/fetchWithPayment.ts +++ b/packages/thirdweb/src/x402/fetchWithPayment.ts @@ -61,9 +61,10 @@ export function wrapFetchWithPayment( return response; } - const { x402Version, accepts } = (await response.json()) as { + const { x402Version, accepts, error } = (await response.json()) as { x402Version: number; accepts: unknown[]; + error?: string; }; const parsedPaymentRequirements = accepts .map((x) => RequestedPaymentRequirementsSchema.parse(x)) @@ -83,6 +84,12 @@ export function wrapFetchWithPayment( "exact", ); + if (!selectedPaymentRequirements) { + throw new Error( + `No suitable payment requirements found for chain ${chain.id}. ${error}`, + ); + } + if (BigInt(selectedPaymentRequirements.maxAmountRequired) > maxValue) { throw new Error( `Payment amount exceeds maximum allowed (currently set to ${maxValue} in base units)`, @@ -154,9 +161,6 @@ function defaultPaymentRequirementsSelector( const firstPaymentRequirement = paymentRequirements.find( (x) => x.scheme === scheme, ); - if (!firstPaymentRequirement) { - throw new Error("No suitable payment requirements found"); - } return firstPaymentRequirement; } } diff --git a/packages/thirdweb/src/x402/schemas.ts b/packages/thirdweb/src/x402/schemas.ts index 065e7a9b618..5840d89cdb8 100644 --- a/packages/thirdweb/src/x402/schemas.ts +++ b/packages/thirdweb/src/x402/schemas.ts @@ -5,6 +5,7 @@ import { PaymentPayloadSchema, PaymentRequirementsSchema, SettleResponseSchema, + SupportedPaymentKindsResponseSchema, } from "x402/types"; import { z } from "zod"; import type { Chain } from "../chains/types.js"; @@ -56,6 +57,44 @@ export type FacilitatorSettleResponse = z.infer< typeof FacilitatorSettleResponseSchema >; +export const SupportedSignatureTypeSchema = z.enum([ + "TransferWithAuthorization", + "Permit", +]); + +export const FacilitatorSupportedAssetSchema = z.object({ + address: z.string(), + decimals: z.number(), + eip712: z.object({ + name: z.string(), + version: z.string(), + primaryType: SupportedSignatureTypeSchema, + }), +}); + +const FacilitatorSupportedResponseSchema = + SupportedPaymentKindsResponseSchema.extend({ + kinds: z.array( + z.object({ + x402Version: z.literal(1), + scheme: z.literal("exact"), + network: FacilitatorNetworkSchema, + extra: z + .object({ + defaultAsset: FacilitatorSupportedAssetSchema.optional(), + supportedAssets: z + .array(FacilitatorSupportedAssetSchema) + .optional(), + }) + .optional(), + }), + ), + }).describe("Supported payment kinds for this facilitator"); + +export type FacilitatorSupportedResponse = z.infer< + typeof FacilitatorSupportedResponseSchema +>; + export function networkToChainId(network: string | Chain): number { if (typeof network === "object") { return network.id; diff --git a/packages/thirdweb/src/x402/types.ts b/packages/thirdweb/src/x402/types.ts index 51260a0e6d3..677f27a8e19 100644 --- a/packages/thirdweb/src/x402/types.ts +++ b/packages/thirdweb/src/x402/types.ts @@ -1,4 +1,5 @@ import type { Money, PaymentMiddlewareConfig } from "x402/types"; +import type z from "zod"; import type { Chain } from "../chains/types.js"; import type { Address } from "../utils/address.js"; import type { Prettify } from "../utils/type-utils.js"; @@ -6,8 +7,10 @@ import type { ThirdwebX402Facilitator } from "./facilitator.js"; import type { FacilitatorNetwork, FacilitatorSettleResponse, + FacilitatorSupportedAssetSchema, RequestedPaymentPayload, RequestedPaymentRequirements, + SupportedSignatureTypeSchema, } from "./schemas.js"; export const x402Version = 1; @@ -86,17 +89,21 @@ export type VerifyPaymentResult = Prettify< | PaymentRequiredResult >; -export type SupportedSignatureType = "TransferWithAuthorization" | "Permit"; +export type SupportedSignatureType = z.infer< + typeof SupportedSignatureTypeSchema +>; export type ERC20TokenAmount = { amount: string; asset: { address: `0x${string}`; - decimals: number; - eip712: { + decimals?: number; + eip712?: { name: string; version: string; primaryType: SupportedSignatureType; }; }; }; + +export type DefaultAsset = z.infer; From 9253e1608036ab86aa625c7e1d45cc120d417166 Mon Sep 17 00:00:00 2001 From: Joaquim Verges Date: Mon, 29 Sep 2025 21:42:50 +1300 Subject: [PATCH 2/3] review --- packages/thirdweb/src/x402/common.ts | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/packages/thirdweb/src/x402/common.ts b/packages/thirdweb/src/x402/common.ts index 86b63526557..85f667b0ca1 100644 --- a/packages/thirdweb/src/x402/common.ts +++ b/packages/thirdweb/src/x402/common.ts @@ -23,6 +23,7 @@ import { type SupportedSignatureType, x402Version, } from "./types.js"; +import { toUnits } from "../utils/units.js"; type GetPaymentRequirementsResult = { status: 200; @@ -222,10 +223,7 @@ async function processPriceToAtomicAmount( }; } asset = defaultAsset; - maxAmountRequired = ( - parsedUsdAmount * - 10 ** defaultAsset.decimals - ).toString(); + maxAmountRequired = toUnits(parsedUsdAmount.toString(), defaultAsset.decimals).toString(); } else { // Token amount in atomic units maxAmountRequired = price.amount; @@ -325,7 +323,7 @@ async function getOrDetectTokenExtras(args: { if ( partialAsset.eip712?.name && partialAsset.eip712?.version && - partialAsset.decimals + partialAsset.decimals !== undefined ) { return { name: partialAsset.eip712.name, From 1b718f05297908509d09f8c950fb8a71c9afacb9 Mon Sep 17 00:00:00 2001 From: Joaquim Verges Date: Mon, 29 Sep 2025 21:43:41 +1300 Subject: [PATCH 3/3] lint --- .../x402/components/X402LeftSection.tsx | 39 +++++++++++++++++++ .../x402/components/X402Playground.tsx | 1 + .../x402/components/X402RightSection.tsx | 4 +- .../app/payments/x402/components/constants.ts | 2 - .../src/app/payments/x402/components/types.ts | 1 + apps/playground-web/src/middleware.ts | 23 +++++++---- packages/thirdweb/src/x402/common.ts | 7 +++- 7 files changed, 64 insertions(+), 13 deletions(-) diff --git a/apps/playground-web/src/app/payments/x402/components/X402LeftSection.tsx b/apps/playground-web/src/app/payments/x402/components/X402LeftSection.tsx index de325817838..7432d154ed1 100644 --- a/apps/playground-web/src/app/payments/x402/components/X402LeftSection.tsx +++ b/apps/playground-web/src/app/payments/x402/components/X402LeftSection.tsx @@ -6,6 +6,13 @@ import { defineChain } from "thirdweb/chains"; import { BridgeNetworkSelector } from "@/components/blocks/NetworkSelectors"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; import { TokenSelector } from "@/components/ui/TokenSelector"; import { THIRDWEB_CLIENT } from "@/lib/client"; import type { TokenMetadata } from "@/lib/types"; @@ -38,6 +45,7 @@ export function X402LeftSection(props: { const tokenId = useId(); const amountId = useId(); const payToId = useId(); + const waitUntilId = useId(); const handleChainChange = (chainId: number) => { setSelectedChain(chainId); @@ -81,6 +89,15 @@ export function X402LeftSection(props: { })); }; + const handleWaitUntilChange = ( + value: "simulated" | "submitted" | "confirmed", + ) => { + setOptions((v) => ({ + ...v, + waitUntil: value, + })); + }; + return (
@@ -146,6 +163,28 @@ export function X402LeftSection(props: { The wallet address that will receive the payment

+ + {/* Wait Until selection */} +
+ + +

+ When to consider the payment settled: simulated (fastest), + submitted (medium), or confirmed (most secure) +

+
diff --git a/apps/playground-web/src/app/payments/x402/components/X402Playground.tsx b/apps/playground-web/src/app/payments/x402/components/X402Playground.tsx index ccc035b11ec..eaf65e5dd33 100644 --- a/apps/playground-web/src/app/payments/x402/components/X402Playground.tsx +++ b/apps/playground-web/src/app/payments/x402/components/X402Playground.tsx @@ -14,6 +14,7 @@ const defaultOptions: X402PlaygroundOptions = { tokenDecimals: token.decimals, amount: "0.01", payTo: "0x0000000000000000000000000000000000000000", + waitUntil: "simulated", }; export function X402Playground() { diff --git a/apps/playground-web/src/app/payments/x402/components/X402RightSection.tsx b/apps/playground-web/src/app/payments/x402/components/X402RightSection.tsx index 77dbebd7eae..a993d69f761 100644 --- a/apps/playground-web/src/app/payments/x402/components/X402RightSection.tsx +++ b/apps/playground-web/src/app/payments/x402/components/X402RightSection.tsx @@ -51,6 +51,7 @@ export function X402RightSection(props: { options: X402PlaygroundOptions }) { searchParams.set("amount", props.options.amount); searchParams.set("tokenAddress", props.options.tokenAddress); searchParams.set("decimals", props.options.tokenDecimals.toString()); + searchParams.set("waitUntil", props.options.waitUntil); const url = "/api/paywall" + @@ -95,6 +96,7 @@ const client = createThirdwebClient({ const thirdwebFacilitator = facilitator({ client, serverWalletAddress: "0xYourServerWalletAddress", + waitUtil: "${props.options.waitUntil}", }); export async function POST(request: Request) { @@ -250,7 +252,7 @@ export async function POST(request: Request) { )} diff --git a/apps/playground-web/src/app/payments/x402/components/constants.ts b/apps/playground-web/src/app/payments/x402/components/constants.ts index d463fbd2342..dc78240a64f 100644 --- a/apps/playground-web/src/app/payments/x402/components/constants.ts +++ b/apps/playground-web/src/app/payments/x402/components/constants.ts @@ -1,5 +1,3 @@ -// const chain = arbitrumSepolia; - import { base } from "thirdweb/chains"; export const chain = base; diff --git a/apps/playground-web/src/app/payments/x402/components/types.ts b/apps/playground-web/src/app/payments/x402/components/types.ts index 879cd33a4e7..adcc0c21a33 100644 --- a/apps/playground-web/src/app/payments/x402/components/types.ts +++ b/apps/playground-web/src/app/payments/x402/components/types.ts @@ -8,4 +8,5 @@ export type X402PlaygroundOptions = { tokenDecimals: number; amount: string; payTo: Address; + waitUntil: "simulated" | "submitted" | "confirmed"; }; diff --git a/apps/playground-web/src/middleware.ts b/apps/playground-web/src/middleware.ts index e6963058a39..81ff1f1ef9d 100644 --- a/apps/playground-web/src/middleware.ts +++ b/apps/playground-web/src/middleware.ts @@ -13,13 +13,17 @@ 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"}`; -const twFacilitator = facilitator({ - baseUrl: `${API_URL}/v1/payments/x402`, - client, - serverWalletAddress: BACKEND_WALLET_ADDRESS, - vaultAccessToken: ENGINE_VAULT_ACCESS_TOKEN, - waitUtil: "simulated", -}); +function createFacilitator( + waitUntil: "simulated" | "submitted" | "confirmed" = "simulated", +) { + return facilitator({ + baseUrl: `${API_URL}/v1/payments/x402`, + client, + serverWalletAddress: BACKEND_WALLET_ADDRESS, + vaultAccessToken: ENGINE_VAULT_ACCESS_TOKEN, + waitUtil: waitUntil, + }); +} export async function middleware(request: NextRequest) { const pathname = request.nextUrl.pathname; @@ -41,6 +45,9 @@ export async function middleware(request: NextRequest) { const amount = queryParams.get("amount") || "0.01"; const tokenAddress = queryParams.get("tokenAddress") || token.address; const decimals = queryParams.get("decimals") || token.decimals.toString(); + const waitUntil = + (queryParams.get("waitUntil") as "simulated" | "submitted" | "confirmed") || + "simulated"; const result = await settlePayment({ resourceUrl, @@ -58,7 +65,7 @@ export async function middleware(request: NextRequest) { routeConfig: { description: "Access to paid content", }, - facilitator: twFacilitator, + facilitator: createFacilitator(waitUntil), }); if (result.status === 200) { diff --git a/packages/thirdweb/src/x402/common.ts b/packages/thirdweb/src/x402/common.ts index 85f667b0ca1..c5fa449381c 100644 --- a/packages/thirdweb/src/x402/common.ts +++ b/packages/thirdweb/src/x402/common.ts @@ -8,6 +8,7 @@ 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 { getAddress } from "../utils/address.js"; +import { toUnits } from "../utils/units.js"; import { decodePayment } from "./encode.js"; import type { ThirdwebX402Facilitator } from "./facilitator.js"; import { @@ -23,7 +24,6 @@ import { type SupportedSignatureType, x402Version, } from "./types.js"; -import { toUnits } from "../utils/units.js"; type GetPaymentRequirementsResult = { status: 200; @@ -223,7 +223,10 @@ async function processPriceToAtomicAmount( }; } asset = defaultAsset; - maxAmountRequired = toUnits(parsedUsdAmount.toString(), defaultAsset.decimals).toString(); + maxAmountRequired = toUnits( + parsedUsdAmount.toString(), + defaultAsset.decimals, + ).toString(); } else { // Token amount in atomic units maxAmountRequired = price.amount;