From bfaf06b52d71f0395590766b0bf89ff0f2b2c46a Mon Sep 17 00:00:00 2001 From: Joaquim Verges Date: Mon, 1 Dec 2025 13:31:13 +1300 Subject: [PATCH] [X402] Improve token selection and UI for payment playground --- .../app/x402/components/X402LeftSection.tsx | 5 +- .../app/x402/components/X402RightSection.tsx | 58 ++++---- .../components/blocks/NetworkSelectors.tsx | 128 ++++++++++++++++++ .../src/components/ui/TokenSelector.tsx | 15 +- apps/playground-web/src/hooks/allChains.ts | 30 ++++ .../playground-web/src/hooks/useTokensData.ts | 83 ++++++++++-- 6 files changed, 281 insertions(+), 38 deletions(-) create mode 100644 apps/playground-web/src/hooks/allChains.ts diff --git a/apps/playground-web/src/app/x402/components/X402LeftSection.tsx b/apps/playground-web/src/app/x402/components/X402LeftSection.tsx index 2709d5eec4b..33da4fc04e0 100644 --- a/apps/playground-web/src/app/x402/components/X402LeftSection.tsx +++ b/apps/playground-web/src/app/x402/components/X402LeftSection.tsx @@ -3,7 +3,7 @@ import type React from "react"; import { useId, useState } from "react"; import { defineChain } from "thirdweb/chains"; -import { BridgeNetworkSelector } from "@/components/blocks/NetworkSelectors"; +import { SingleNetworkSelector } from "@/components/blocks/NetworkSelectors"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { @@ -106,7 +106,7 @@ export function X402LeftSection(props: { {/* Chain selection */}
- { const searchParams = new URLSearchParams(); searchParams.set("chainId", props.options.chain.id.toString()); @@ -140,34 +145,36 @@ export async function POST(request: Request) { > - {previewTab === "ui" && ( + {previewTab === "ui" && !isTokenSelected && ( + + +

Select Payment Token

+

+ Please select a chain and payment token from the configuration + panel to continue. +

+
+ )} + + {previewTab === "ui" && isTokenSelected && (
@@ -175,8 +182,7 @@ export async function POST(request: Request) { Paid API Call - {props.options.amount}{" "} - {props.options.tokenSymbol || "tokens"} + {props.options.amount} {props.options.tokenSymbol}
@@ -190,7 +196,7 @@ export async function POST(request: Request) { Access Premium Content

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

diff --git a/apps/playground-web/src/components/blocks/NetworkSelectors.tsx b/apps/playground-web/src/components/blocks/NetworkSelectors.tsx index 8fa6e142979..34f2fd4d0bb 100644 --- a/apps/playground-web/src/components/blocks/NetworkSelectors.tsx +++ b/apps/playground-web/src/components/blocks/NetworkSelectors.tsx @@ -1,9 +1,11 @@ "use client"; +import { Badge } from "@workspace/ui/components/badge"; import { useCallback, useMemo } from "react"; import { ChainIcon } from "@/components/blocks/ChainIcon"; import { SelectWithSearch } from "@/components/ui/select-with-search"; import { useBridgeSupportedChains } from "@/hooks/chains"; +import { useAllChainsData } from "../../hooks/allChains"; function cleanChainName(chainName: string) { return chainName.replace("Mainnet", ""); @@ -11,6 +13,132 @@ function cleanChainName(chainName: string) { type Option = { label: string; value: string }; +export function SingleNetworkSelector(props: { + chainId: number | undefined; + onChange: (chainId: number) => void; + className?: string; + popoverContentClassName?: string; + // if specified - only these chains will be shown + chainIds?: number[]; + side?: "left" | "right" | "top" | "bottom"; + disableChainId?: boolean; + align?: "center" | "start" | "end"; + disableTestnets?: boolean; + disableDeprecated?: boolean; + placeholder?: string; +}) { + const allChains = useAllChainsData(); + + const chainsToShow = useMemo(() => { + let chains = allChains || []; + + if (props.disableTestnets) { + chains = chains.filter((chain) => !chain.testnet); + } + + if (props.chainIds) { + const chainIdSet = new Set(props.chainIds); + chains = chains.filter((chain) => chainIdSet.has(chain.chainId)); + } + + if (props.disableDeprecated) { + chains = chains.filter((chain) => chain.status !== "deprecated"); + } + + return chains; + }, [ + allChains, + props.chainIds, + props.disableTestnets, + props.disableDeprecated, + ]); + + const options = useMemo(() => { + return chainsToShow.map((chain) => { + return { + label: chain.name, + value: String(chain.chainId), + }; + }); + }, [chainsToShow]); + + const searchFn = useCallback( + (option: Option, searchValue: string) => { + const chain = chainsToShow.find( + (chain) => chain.chainId === Number(option.value), + ); + if (!chain) { + return false; + } + + if (Number.isInteger(Number(searchValue))) { + return String(chain.chainId).startsWith(searchValue); + } + return chain.name.toLowerCase().includes(searchValue.toLowerCase()); + }, + [chainsToShow], + ); + + const renderOption = useCallback( + (option: Option) => { + const chain = chainsToShow.find( + (chain) => chain.chainId === Number(option.value), + ); + if (!chain) { + return option.label; + } + + return ( +
+ + + {cleanChainName(chain.name)} + + + {!props.disableChainId && ( + + Chain ID + {chain.chainId} + + )} +
+ ); + }, + [chainsToShow, props.disableChainId], + ); + + const isLoadingChains = !allChains || allChains.length === 0; + + return ( + { + props.onChange(Number(chainId)); + }} + options={options} + overrideSearchFn={searchFn} + placeholder={ + isLoadingChains + ? "Loading Chains..." + : props.placeholder || "Select Chain" + } + popoverContentClassName={props.popoverContentClassName} + renderOption={renderOption} + searchPlaceholder="Search by Name or Chain ID" + showCheck={false} + side={props.side} + value={props.chainId ? String(props.chainId) : undefined} + /> + ); +} + export function BridgeNetworkSelector(props: { chainId: number | undefined; onChange: (chainId: number) => void; diff --git a/apps/playground-web/src/components/ui/TokenSelector.tsx b/apps/playground-web/src/components/ui/TokenSelector.tsx index 6ed20a844be..47a65ba6fff 100644 --- a/apps/playground-web/src/components/ui/TokenSelector.tsx +++ b/apps/playground-web/src/components/ui/TokenSelector.tsx @@ -2,7 +2,7 @@ import { CoinsIcon } from "lucide-react"; import { useCallback, useMemo } from "react"; -import type { ThirdwebClient } from "thirdweb"; +import { NATIVE_TOKEN_ADDRESS, type ThirdwebClient } from "thirdweb"; import { shortenAddress } from "thirdweb/utils"; import { Badge } from "@/components/ui/badge"; import { Img } from "@/components/ui/Img"; @@ -21,13 +21,24 @@ export function TokenSelector(props: { client: ThirdwebClient; disabled?: boolean; enabled?: boolean; + includeNativeToken?: boolean; }) { const tokensQuery = useTokensData({ chainId: props.chainId, enabled: props.enabled, }); - const tokens = tokensQuery.data || []; + const tokens = useMemo(() => { + if (props.includeNativeToken === false) { + return ( + tokensQuery.data?.filter( + (token) => + token.address.toLowerCase() !== NATIVE_TOKEN_ADDRESS.toLowerCase(), + ) || [] + ); + } + return tokensQuery.data || []; + }, [tokensQuery.data, props.includeNativeToken]); const addressChainToToken = useMemo(() => { const value = new Map(); for (const token of tokens) { diff --git a/apps/playground-web/src/hooks/allChains.ts b/apps/playground-web/src/hooks/allChains.ts new file mode 100644 index 00000000000..38bf160be19 --- /dev/null +++ b/apps/playground-web/src/hooks/allChains.ts @@ -0,0 +1,30 @@ +"use client"; + +import { useQuery } from "@tanstack/react-query"; +import type { ChainMetadata } from "thirdweb/chains"; + +async function fetchChainsFromApi() { + // always fetch from prod for chains for now + // TODO: re-visit this + const res = await fetch("https://api.thirdweb.com/v1/chains"); + const json = await res.json(); + + if (json.error || !res.ok) { + throw new Error( + json.error?.message || `Failed to fetch chains: ${res.status}`, + ); + } + + return json.data as ChainMetadata[]; +} + +export function useAllChainsData() { + // trigger fetching all chains if this hook is used instead of putting this on root + // so we can avoid fetching all chains if it's not required + const allChainsQuery = useQuery({ + queryFn: () => fetchChainsFromApi(), + queryKey: ["all-chains"], + }); + + return allChainsQuery.data; +} diff --git a/apps/playground-web/src/hooks/useTokensData.ts b/apps/playground-web/src/hooks/useTokensData.ts index 4ed8b1eca43..d54a8975fce 100644 --- a/apps/playground-web/src/hooks/useTokensData.ts +++ b/apps/playground-web/src/hooks/useTokensData.ts @@ -4,18 +4,24 @@ import { useQuery } from "@tanstack/react-query"; import type { TokenMetadata } from "@/lib/types"; async function fetchTokensFromApi(chainId?: number) { - const domain = process.env.NEXT_PUBLIC_BRIDGE_URL || "bridge.thirdweb.com"; - const url = new URL( - `${domain.includes("localhost") ? "http" : "https"}://${domain}/v1/tokens`, + const bridgeDomain = + process.env.NEXT_PUBLIC_BRIDGE_URL || "bridge.thirdweb.com"; + const apiDomain = process.env.NEXT_PUBLIC_API_URL || "api.thirdweb.com"; + const bridgeUrl = new URL( + `${bridgeDomain.includes("localhost") ? "http" : "https"}://${bridgeDomain}/v1/tokens`, + ); + const apiUrl = new URL( + `${apiDomain.includes("localhost") ? "http" : "https"}://${apiDomain}/v1/payments/x402/supported`, ); if (chainId) { - url.searchParams.append("chainId", String(chainId)); + bridgeUrl.searchParams.append("chainId", String(chainId)); + apiUrl.searchParams.append("chainId", String(chainId)); } - url.searchParams.append("limit", "1000"); - url.searchParams.append("includePrices", "false"); + bridgeUrl.searchParams.append("limit", "1000"); + bridgeUrl.searchParams.append("includePrices", "false"); - const res = await fetch(url.toString(), { + const res = await fetch(bridgeUrl.toString(), { headers: { "Content-Type": "application/json", "x-client-id": process.env.NEXT_PUBLIC_THIRDWEB_CLIENT_ID || "", @@ -31,7 +37,68 @@ async function fetchTokensFromApi(chainId?: number) { throw new Error(json.error.message); } - return json.data as Array; + const bridgeTokens = json.data as Array; + + if (bridgeTokens.length === 0 && chainId) { + // try the x402 supported api, which supports testnets + const apiRes = await fetch(apiUrl.toString(), { + headers: { + "Content-Type": "application/json", + "x-client-id": process.env.NEXT_PUBLIC_THIRDWEB_CLIENT_ID || "", + }, + }); + if (!apiRes.ok) { + throw new Error("Failed to fetch supported tokens"); + } + const apiJson = await apiRes.json(); + if (apiJson.error) { + throw new Error(apiJson.error.message); + } + const apiTokens = apiJson as { + kinds: Array<{ + x402Version: 1; + scheme: "exact"; + network: string; + extra: { + defaultAsset: { + address: string; + decimals: number; + eip712: { + name: string; + version: string; + primaryType: "TransferWithAuthorization" | "Permit"; + }; + }; + supportedAssets?: Array<{ + address: string; + decimals: number; + eip712: { + name: string; + version: string; + primaryType: "TransferWithAuthorization" | "Permit"; + }; + }>; + }; + }>; + }; + const result = apiTokens.kinds + .flatMap((token) => { + const assets = token.extra?.defaultAsset + ? [token.extra?.defaultAsset] + : (token.extra?.supportedAssets ?? []); + return assets.map((asset) => ({ + chainId: chainId, + address: asset.address, + decimals: asset.decimals, + symbol: asset.eip712.name, + name: asset.eip712.name, + })) as TokenMetadata[]; + }) + .filter((token) => token !== undefined) + .flat(); + return result; + } + return bridgeTokens; } export function useTokensData({