onchain-ui
uichain-selectsource: ok · hit
Chain Select
Network selector for wallet switching or filtering, with optional all-networks and unsupported-chain states.
npx shadcn add "https://onchain-ui.dev/r/chain-select.json"free
Where it goes
stats
Interaction
animated-counterdropdown
Tags auto-generated by rules on 9/24/2026 — empty means no evidence, not lack of support.
Dependencies
Bundle weight · lightviem@2.55.19
Registry dependencies
dropdown-menutooltip
Files4 · source cached 10/8/2026, 5:56:33 PM
registry/onchain-ui/chain-select.tsx
"use client";
import { useMemo, type ReactNode } from "react";
import {
ChevronDownIcon,
EarthIcon,
LoaderCircleIcon,
TriangleAlertIcon,
} from "lucide-react";
import { cn } from "@/lib/utils";
import { NetworkLogo } from "./network-logo";
import { getNetworkMeta } from "@/lib/onchain/config";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuLabel,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
export interface ChainSelectOption {
/** EVM chain id */
id: number;
/** Display name. Falls back to the built-in network name, then the chain id */
name?: string | null;
/** Network image URL override, forwarded to NetworkLogo */
logoSrc?: string | null;
/** Short symbol, used for the logo fallback */
symbol?: string | null;
/** Render but do not allow selection */
disabled?: boolean;
/** Rendered after the name, e.g. a "Testnet" badge */
badge?: ReactNode;
}
export interface ChainSelectProps {
/** Selectable chains, usually the ones your app is configured for */
chains: Array<ChainSelectOption>;
/** Selected chain id. Null when disconnected or showing all networks */
value?: number | null;
/** Called with the chain id the user picked */
onSelect: (chainId: number) => void;
/** Called when the optional all-networks item is picked */
onClear?: () => void;
/** Renders an item that clears the selected chain, e.g. "All networks" */
allLabel?: string;
/** Chain id currently being switched to. Blocks further selection */
pendingChainId?: number | null;
/** Disable the whole control */
disabled?: boolean;
/** Trigger label when `value` is null. Default: "Select network" */
placeholder?: string;
/**
* Trigger label when `value` is set but missing from `chains` — a wallet on a
* network the app does not support. Default: "Unsupported network"
*/
unsupportedLabel?: string;
/** Heading above the menu items. Default: "Switch network" */
menuLabel?: ReactNode;
/** Show the network name in the trigger. Default: true */
showLabel?: boolean;
/** Visual size. Default: "default" */
size?: "sm" | "default";
/** Menu alignment against the trigger. Default: "start" */
align?: "start" | "center" | "end";
/** Applied to the trigger */
className?: string;
/** Applied to the menu */
contentClassName?: string;
}
const triggerSizes = {
sm: "h-7 gap-1.5 px-2 text-xs",
default: "h-8 gap-2 px-2.5 text-[0.8rem]",
} as const;
const logoSizes = {
sm: "xs",
default: "sm",
} as const;
export function ChainSelect({
chains,
value,
onSelect,
onClear,
allLabel,
pendingChainId,
disabled = false,
placeholder = "Select network",
unsupportedLabel = "Unsupported network",
menuLabel = "Switch network",
showLabel = true,
size = "default",
align = "start",
className,
contentClassName,
}: ChainSelectProps) {
const selected = useMemo(
() => chains.find((chain) => chain.id === value) ?? null,
[chains, value]
);
// A connected chain that is not in `chains` is the wrong-network case: the
// wallet is somewhere the app cannot serve. Say so instead of rendering a
// bare chain id and hoping the user works it out.
const isUnsupported = value != null && selected === null;
const isPending = pendingChainId != null;
const logoSize = logoSizes[size];
// Explicit name wins, then the curated network name, then the placeholder.
// Falling back through the config means a caller can pass bare chain ids and
// still get "Optimism" rather than viem's "OP Mainnet".
const chainLabel = (chain: ChainSelectOption) =>
chain.name ?? getNetworkMeta(chain.id).name ?? `Chain ${chain.id}`;
const triggerLabel = isUnsupported
? unsupportedLabel
: selected
? chainLabel(selected)
: (allLabel ?? placeholder);
return (
<DropdownMenu>
<DropdownMenuTrigger
disabled={disabled || isPending || chains.length === 0}
aria-label={showLabel ? undefined : triggerLabel}
data-unsupported={isUnsupported || undefined}
className={cn(
"inline-flex shrink-0 items-center rounded-lg border border-border bg-background font-medium whitespace-nowrap",
"transition-colors duration-150 outline-none",
"hover:bg-muted focus-visible:ring-3 focus-visible:ring-ring/50",
"disabled:pointer-events-none disabled:opacity-50",
"data-unsupported:border-destructive/40 data-unsupported:text-destructive",
triggerSizes[size],
className
)}
>
{isPending ? (
<LoaderCircleIcon
className={cn("animate-spin", size === "sm" ? "size-3.5" : "size-4")}
aria-hidden="true"
/>
) : value == null && allLabel ? (
<EarthIcon
className={cn(size === "sm" ? "size-5" : "size-6")}
aria-hidden="true"
/>
) : value == null ? (
// Nothing is connected, so there is no chain to illustrate. An empty
// slot holds the trigger's width without inventing a network.
<span
className={cn(
"shrink-0 rounded-full border border-dashed border-border",
size === "sm" ? "size-5" : "size-6"
)}
aria-hidden="true"
/>
) : (
<NetworkLogo
chainId={value}
src={selected?.logoSrc}
name={selected?.name}
symbol={selected?.symbol}
size={logoSize}
// The warning glyph replaces the chain-id text fallback, which does
// not fit the logo at these sizes.
fallback={
isUnsupported ? (
<TriangleAlertIcon className="size-3.5" />
) : undefined
}
className={cn(
isUnsupported && "border-destructive/40 text-destructive"
)}
/>
)}
{showLabel && <span className="min-w-0 truncate">{triggerLabel}</span>}
<ChevronDownIcon
className={cn(
"text-muted-foreground",
size === "sm" ? "size-3" : "size-3.5"
)}
aria-hidden="true"
/>
</DropdownMenuTrigger>
<DropdownMenuContent align={align} className={cn("w-52", contentClassName)}>
<DropdownMenuRadioGroup
value={value ?? null}
onValueChange={(next) => {
if (next == null) {
onClear?.();
return;
}
onSelect(next as number);
}}
>
{/* GroupLabel reads its group from context, so it has to live inside
the radio group rather than above it. */}
{menuLabel && <DropdownMenuLabel>{menuLabel}</DropdownMenuLabel>}
{allLabel && onClear && (
<DropdownMenuRadioItem
value={null}
disabled={isPending}
closeOnClick
className="gap-2 py-1.5"
>
<EarthIcon className="size-5" aria-hidden="true" />
<span className="min-w-0 truncate">{allLabel}</span>
</DropdownMenuRadioItem>
)}
{chains.map((chain) => (
<DropdownMenuRadioItem
key={chain.id}
value={chain.id}
disabled={chain.disabled || isPending}
// Radio items keep the menu open by default, which suits a
// settings menu. Picking a network is a one-shot action.
closeOnClick
className="gap-2 py-1.5"
>
<NetworkLogo
chainId={chain.id}
src={chain.logoSrc}
name={chain.name}
symbol={chain.symbol}
size="xs"
/>
<span className="min-w-0 truncate">
{chainLabel(chain)}
</span>
{chain.badge}
{pendingChainId === chain.id && (
<LoaderCircleIcon
className="ml-auto size-3.5 animate-spin text-muted-foreground"
aria-hidden="true"
/>
)}
</DropdownMenuRadioItem>
))}
</DropdownMenuRadioGroup>
</DropdownMenuContent>
</DropdownMenu>
);
}
registry/onchain-ui/network-logo.tsx
"use client";
import { useMemo, useState, type ReactNode } from "react";
import { cn } from "@/lib/utils";
import { getNetworkIcon } from "@/lib/onchain/crypto-icons";
import { getNetworkMeta } from "@/lib/onchain/config";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
const sizeClasses = {
xs: "size-5 text-[8px]",
sm: "size-6 text-[9px]",
md: "size-8 text-[10px]",
lg: "size-10 text-xs",
xl: "size-12 text-sm",
} as const;
export interface NetworkLogoProps {
/** EVM chain id */
chainId?: number | null;
/** Network image URL override */
src?: string | null;
/** Network name, used for alt text and fallback */
name?: string | null;
/** Short network symbol, used for fallback */
symbol?: string | null;
/** Visual size. Default: md */
size?: keyof typeof sizeClasses;
/** Custom fallback content for unsupported chains */
fallback?: ReactNode;
/** Show an animated tooltip. Default: false */
showTooltip?: boolean;
/** Tooltip content. Defaults to network name, symbol, then chain id */
tooltipContent?: ReactNode;
/** Applied to the root wrapper */
className?: string;
/** Applied to the image */
imageClassName?: string;
/** Applied to the fallback */
fallbackClassName?: string;
}
// How many characters fit inside the circle at each size, measured rather than
// derived: the font and the circle do not grow in step, so the limit is not a
// constant ratio. Values leave ~20% slack so glyphs never touch the border.
const fallbackMaxChars = {
xs: 3,
sm: 3,
md: 4,
lg: 4,
xl: 4,
} as const;
function getFallbackText({
chainId,
name,
symbol,
maxChars,
}: Pick<NetworkLogoProps, "chainId" | "name" | "symbol"> & {
maxChars: number;
}) {
if (symbol) return symbol.slice(0, Math.min(4, maxChars)).toUpperCase();
if (name) return name.slice(0, 2).toUpperCase();
// A chain id is only shown when it fits whole. Truncating one produces a
// different, real-looking chain id, which is worse than admitting we do not
// recognise the network — the full id stays in `alt` and the tooltip.
if (chainId != null) {
const id = String(chainId);
if (id.length <= maxChars) return id;
}
return "?";
}
export function NetworkLogo({
chainId,
src,
name,
symbol,
size = "md",
fallback,
showTooltip = false,
tooltipContent,
className,
imageClassName,
fallbackClassName,
}: NetworkLogoProps) {
const [failedSrc, setFailedSrc] = useState<string | null>(null);
const knownNetwork = getNetworkMeta(chainId);
// Explicit src wins; otherwise ask the crypto-icons adapter.
const adapterIcon = src ? null : getNetworkIcon(chainId);
const resolvedSrc =
src ?? (typeof adapterIcon === "string" ? adapterIcon : null);
const knownIcon =
adapterIcon != null && typeof adapterIcon !== "string"
? adapterIcon
: null;
const displayName = name ?? knownNetwork.name;
const displaySymbol = symbol ?? knownNetwork.symbol;
const shouldShowImage = Boolean(resolvedSrc) && failedSrc !== resolvedSrc;
const alt = useMemo(() => {
if (displayName && displaySymbol) return `${displayName} (${displaySymbol})`;
return displayName ?? displaySymbol ?? String(chainId ?? "Network");
}, [chainId, displayName, displaySymbol]);
const tooltip = tooltipContent ?? displayName ?? displaySymbol ?? chainId;
const logo = (
<span
className={cn(
"relative inline-flex shrink-0 items-center justify-center overflow-hidden rounded-full border bg-muted font-semibold text-muted-foreground",
sizeClasses[size],
className
)}
>
{shouldShowImage ? (
<img
src={resolvedSrc ?? undefined}
alt={alt}
className={cn("size-full object-cover", imageClassName)}
loading="lazy"
decoding="async"
referrerPolicy="no-referrer"
onError={() => setFailedSrc(resolvedSrc)}
/>
) : knownIcon && !fallback ? (
knownIcon
) : (
<span className={cn("select-none", fallbackClassName)}>
{fallback ??
getFallbackText({
chainId,
name: displayName,
symbol: displaySymbol,
maxChars: fallbackMaxChars[size],
})}
</span>
)}
</span>
);
if (!showTooltip || tooltip == null) return logo;
return (
<Tooltip>
<TooltipTrigger render={logo} />
<TooltipContent>{tooltip}</TooltipContent>
</Tooltip>
);
}
lib/onchain/crypto-icons.tsx
import { getAddress } from "viem";
import type { ReactNode } from "react";
/**
* Crypto icon adapter.
*
* This file is the single seam onchain-ui components use to resolve token and
* network icons — the crypto equivalent of shadcn's icon library setting.
* Swap the implementations to point at your icon package, token list, CDN, or
* indexer; the component contract stays the same.
*
* Both functions may return:
* - a string, treated as an image URL
* - a ReactNode, rendered directly in place of the image
* - null, meaning "unknown" — components fall back to readable initials
*
* Explicit props (`src`, `fallback`) always win over this adapter.
*/
export type CryptoIcon = string | ReactNode | null;
export interface TokenIconQuery {
/** EVM chain id the token lives on */
chainId?: number | null;
/** Token contract address */
address?: string | null;
/** Token symbol. Unused by the default adapter: symbols are not canonical */
symbol?: string | null;
/** Whether this is the chain's native currency rather than an ERC-20 */
isNative?: boolean;
}
// Chain folder names in the Trust Wallet assets repo.
const trustWalletChainFolders: Record<number, string> = {
1: "ethereum",
10: "optimism",
137: "polygon",
8453: "base",
42161: "arbitrum",
};
/**
* Resolves a token icon from the community-maintained Trust Wallet assets
* repo by chain id and contract address. Coverage is good for established
* tokens; misses degrade to the component's initials fallback.
*
* This default is fine for demos and prototypes. In production, prefer your
* own token metadata (`src`) or point this function at your token list or
* CDN.
*/
export function getTokenIcon(query: TokenIconQuery): CryptoIcon {
const { chainId, address, isNative, symbol } = query;
if (isNative && symbol?.toUpperCase() === "ETH") {
return <EthereumMark className="size-full" />;
}
if (chainId == null || !address) return null;
const chainFolder = trustWalletChainFolders[chainId];
if (!chainFolder) return null;
try {
// The repo stores assets under EIP-55 checksummed addresses.
const checksummed = getAddress(address);
return `https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/${chainFolder}/assets/${checksummed}/logo.png`;
} catch {
return null;
}
}
function EthereumMark({ className }: { className?: string }) {
return (
<svg viewBox="0 0 32 32" aria-hidden="true" className={className}>
<circle cx="16" cy="16" r="16" fill="#627EEA" />
<g fill="#FFF">
<path fillOpacity=".602" d="M16.498 4v8.87l7.497 3.35z" />
<path d="M16.498 4L9 16.22l7.498-3.35z" />
<path fillOpacity=".602" d="M16.498 21.968v6.027L24 17.616z" />
<path d="M16.498 27.995v-6.028L9 17.616z" />
<path fillOpacity=".2" d="M16.498 20.573l7.497-4.353-7.497-3.348z" />
<path fillOpacity=".602" d="M9 16.22l7.498 4.353v-7.701z" />
</g>
</svg>
);
}
function BaseMark({ className }: { className?: string }) {
return (
<svg viewBox="0 0 146 146" aria-hidden="true" className={className}>
<circle cx="73" cy="73" r="73" fill="#0052FF" />
<path
d="M73.323 123.729C101.617 123.729 124.553 100.832 124.553 72.5875C124.553 44.343 101.617 21.4463 73.323 21.4463C46.4795 21.4463 24.4581 42.0558 22.271 68.2887H89.9859V76.8864H22.271C24.4581 103.119 46.4795 123.729 73.323 123.729Z"
fill="white"
/>
</svg>
);
}
function OptimismMark({ className }: { className?: string }) {
return (
<svg viewBox="0 0 24 24" aria-hidden="true" className={className}>
<circle cx="12" cy="12" r="12" fill="#FF0420" />
<path
fillRule="evenodd"
clipRule="evenodd"
d="M4.85865 15.3781C5.43829 15.7927 6.18265 16 7.09173 16C8.19009 16 9.06774 15.7517 9.72466 15.2553C10.3809 14.7516 10.8427 13.9926 11.1099 12.978C11.2703 12.3558 11.4079 11.7149 11.5226 11.0554C11.5598 10.8196 11.5788 10.6231 11.5795 10.4659C11.5795 9.94787 11.4459 9.50377 11.1786 9.13373C10.9039 8.74954 10.5214 8.4555 10.0793 8.28884C9.61364 8.09628 9.08706 8 8.49956 8C6.34017 8 5.00078 9.01845 4.48139 11.0554C4.29802 11.804 4.15686 12.4448 4.05796 12.978C4.02335 13.176 4.00397 13.3764 4 13.5773C4 14.3554 4.28622 14.9557 4.85865 15.3781ZM8.99176 12.9112C8.77268 13.762 8.16848 14.3161 7.25186 14.3161C6.34508 14.3161 6.03561 13.703 6.19869 12.9112C6.33623 12.1927 6.47377 11.5852 6.61132 11.0888C6.8471 10.1702 7.40316 9.68389 8.35023 9.68389C9.25407 9.68389 9.5488 10.2891 9.3926 11.0888C9.3009 11.6075 9.16729 12.215 8.99176 12.9112ZM12.4608 15.9204C12.5059 15.9728 12.5691 15.999 12.6504 15.999H14.1653C14.2427 15.9981 14.3174 15.9703 14.3765 15.9204C14.4423 15.8699 14.4862 15.796 14.4993 15.7141L14.9778 13.4742H16.5182C17.4909 13.4742 18.2513 13.0026 18.8083 12.583C19.3729 12.1638 19.7477 11.5162 19.9332 10.6398C19.9784 10.4342 20.0006 10.236 20 10.0454C20 9.38327 19.7477 8.87666 19.2435 8.5256C18.7457 8.1752 18.0849 8 17.2609 8H14.2989C14.2209 8.00126 14.1458 8.02976 14.0867 8.08056C14.0222 8.13166 13.9788 8.20477 13.9649 8.28589L12.4274 15.7141C12.411 15.7846 12.423 15.8587 12.4608 15.9204ZM17.874 10.6172C17.7345 11.2293 17.201 11.7893 16.5742 11.7893H15.2941L15.7352 9.68389H17.0713C17.5262 9.68389 17.9064 9.77428 17.9064 10.2743C17.9064 10.3726 17.8963 10.4868 17.874 10.6172Z"
fill="white"
/>
</svg>
);
}
function ArbitrumMark({ className }: { className?: string }) {
return (
<svg viewBox="0 0 24 24" aria-hidden="true" className={className}>
<circle cx="12" cy="12" r="12" fill="#213147" />
<path
d="M13.203 13.2158L12.4164 15.3402C12.3951 15.3993 12.3951 15.4636 12.4164 15.5227L13.7695 19.178L15.3346 18.2879L13.4562 13.2158C13.4136 13.099 13.2456 13.099 13.203 13.2158Z"
fill="white"
/>
<path
d="M14.78 9.64306C14.7373 9.52621 14.5693 9.52621 14.5267 9.64306L13.7402 11.7674C13.7188 11.8265 13.7188 11.8909 13.7402 11.9499L15.9571 17.9345L17.5221 17.0443L14.78 9.64306Z"
fill="white"
/>
<path
d="M11.9985 4.9913C12.0371 4.9913 12.0758 5.0018 12.1104 5.02019L18.0787 8.41424C18.148 8.45363 18.1907 8.52716 18.1907 8.60462V15.3914C18.1907 15.4702 18.148 15.5424 18.0787 15.5818L12.1104 18.9759C12.0772 18.9955 12.0371 19.0047 11.9985 19.0047C11.9598 19.0047 11.9211 18.9943 11.8865 18.9759L5.91826 15.5844C5.84894 15.545 5.80628 15.4715 5.80628 15.3941V8.60593C5.80628 8.52716 5.84894 8.45494 5.91826 8.41555L11.8865 5.0215C11.9211 5.0018 11.9598 4.9913 11.9985 4.9913ZM11.9985 4C11.7865 4 11.5732 4.05383 11.3826 4.16281L5.41568 7.55555C5.03442 7.77219 4.7998 8.17265 4.7998 8.60593V15.3927C4.7998 15.826 5.03442 16.2265 5.41568 16.4431L11.3839 19.8371C11.5745 19.9448 11.7865 20 11.9998 20C12.2118 20 12.425 19.9462 12.6157 19.8371L18.5839 16.4431C18.9652 16.2265 19.1998 15.826 19.1998 15.3927V8.60593C19.1998 8.17265 18.9652 7.77219 18.5839 7.55555L12.6143 4.16281C12.4237 4.05383 12.2104 4 11.9985 4Z"
fill="white"
/>
<path d="M8.05225 17.9426L8.60149 16.4615L9.70662 17.3662L8.67348 18.2958L8.05225 17.9426Z" fill="white" />
<path
d="M11.4965 8.12012H9.98351C9.87019 8.12012 9.76888 8.1897 9.73022 8.29474L6.48682 17.0523L8.05186 17.9425L11.6232 8.29868C11.6565 8.21203 11.5912 8.12012 11.4965 8.12012Z"
fill="white"
/>
<path
d="M14.144 8.12012H12.6309C12.5176 8.12012 12.4163 8.1897 12.3776 8.29474L8.67432 18.2944L10.2393 19.1846L14.2706 8.29868C14.3026 8.21203 14.2373 8.12012 14.144 8.12012Z"
fill="white"
/>
</svg>
);
}
function PolygonMark({ className }: { className?: string }) {
return (
<svg viewBox="0 0 24 24" aria-hidden="true" className={className}>
<defs>
<linearGradient
id="onchain-ui-polygon-bg"
x1="3.948"
y1="16.617"
x2="19.217"
y2="7.645"
gradientUnits="userSpaceOnUse"
>
<stop stopColor="#A726C1" />
<stop offset="0.88" stopColor="#803BDF" />
<stop offset="1" stopColor="#7B3FE4" />
</linearGradient>
</defs>
<circle cx="12" cy="12" r="12" fill="url(#onchain-ui-polygon-bg)" />
<path
d="M15.8794 14.8595L19.6745 12.6947C19.8757 12.5797 20 12.3664 20 12.1375V7.80782C20 7.57894 19.8757 7.36569 19.6745 7.25069L15.8794 5.08583C15.6782 4.97083 15.4284 4.97195 15.2284 5.08583L11.4332 7.25069C11.232 7.36569 11.1077 7.57894 11.1077 7.80782V15.545L8.44614 17.0623L5.78456 15.545V12.5093L8.44614 10.992L10.2013 11.9935V9.95705L8.77163 9.1409C8.67331 9.08508 8.56029 9.05493 8.44614 9.05493C8.33199 9.05493 8.21897 9.08508 8.12065 9.1409L4.32549 11.3058C4.12432 11.4208 4 11.634 4 11.8629V16.1926C4 16.4215 4.12432 16.6347 4.32549 16.7497L8.12065 18.9146C8.32182 19.0285 8.57046 19.0285 8.77163 18.9146L12.5668 16.7497C12.768 16.6347 12.8923 16.4215 12.8923 16.1926V8.45538L12.9397 8.42859L15.5527 6.93808L18.2143 8.45538V11.4911L15.5527 13.0084L13.7998 12.0092V14.0456L15.2272 14.8595C15.4284 14.9734 15.6782 14.9734 15.8782 14.8595H15.8794Z"
fill="white"
/>
</svg>
);
}
/**
* Resolves a network icon by chain id. The default adapter ships crisp inline
* marks for common chains so `<NetworkLogo chainId={8453} />` works offline
* with zero configuration.
*/
export function getNetworkIcon(chainId?: number | null): CryptoIcon {
if (chainId === 1) return <EthereumMark className="size-full" />;
if (chainId === 10) return <OptimismMark className="size-full" />;
if (chainId === 137) return <PolygonMark className="size-full" />;
if (chainId === 8453) return <BaseMark className="size-full" />;
if (chainId === 42161) return <ArbitrumMark className="size-full" />;
return null;
}
lib/onchain/config.ts
import { arbitrum, base, mainnet, optimism, polygon } from "viem/chains";
import type { AssetGatewayUrls, Chain, PublicClient } from "viem";
/** The small client surface identity resolution actually consumes. */
export type OnchainReadClient = Pick<
PublicClient,
"getEnsAddress" | "getEnsAvatar" | "getEnsName" | "readContract"
>;
/**
* The single place onchain-ui components look for app-wide settings.
*
* This file is yours: edit it once and every component follows. There is no
* provider and no context — components import these functions directly, so
* they work the same in a server component, a test, or a non-React consumer.
*
* Per-instance overrides always win. `<NetworkLogo name="…" />` and
* `<AddressDisplay explorerUrl="…" />` beat anything configured here.
*/
export interface OnchainUIConfig {
/**
* Chains your app supports. Supplies block explorer URLs and display names
* for networks that have no built-in entry below.
*
* Using wagmi? Point this at the config you already wrote:
*
* ```ts
* import { wagmiConfig } from "@/lib/onchain/wagmi";
* chains: wagmiConfig.chains,
* ```
*/
chains: readonly Chain[];
/**
* Returns a viem client for reads that are not tied to a rendered component
* — currently ENS and Basename resolution. Leave undefined to use the public
* endpoints below.
*
* Using wagmi? Share its transports instead of configuring RPC twice:
*
* ```ts
* import { getPublicClient } from "@wagmi/core";
* import { wagmiConfig } from "@/lib/onchain/wagmi";
* getClient: (chainId) => getPublicClient(wagmiConfig, { chainId }),
* ```
*/
getClient?: (chainId: number) => OnchainReadClient | undefined;
/**
* Public gateways used to turn IPFS and Arweave avatar records into URLs.
* Replace these with your own gateway in production when reliability or
* rate limits matter.
*/
avatarGatewayUrls?: AssetGatewayUrls;
}
export const onchainConfig: OnchainUIConfig = {
chains: [mainnet, base, arbitrum, optimism, polygon],
avatarGatewayUrls: {
ipfs: "https://gateway.pinata.cloud",
arweave: "https://arweave.net",
},
};
/**
* Curated network names and symbols.
*
* These are deliberately not derived from the viem chain: `chain.name` gives
* "OP Mainnet" where a badge wants "Optimism", and `nativeCurrency.symbol`
* gives "ETH" for Base, Optimism, and Arbitrum alike, which is useless as a
* network mark. Chains absent here fall back to the configured chain's name.
*/
const knownNetworks: Partial<
Record<number, { name: string; symbol: string }>
> = {
1: { name: "Ethereum", symbol: "ETH" },
10: { name: "Optimism", symbol: "OP" },
137: { name: "Polygon", symbol: "POL" },
8453: { name: "Base", symbol: "BASE" },
42161: { name: "Arbitrum", symbol: "ARB" },
};
function findChain(chainId?: number | null) {
if (chainId == null) return null;
return onchainConfig.chains.find((chain) => chain.id === chainId) ?? null;
}
export interface NetworkMeta {
name: string | null;
symbol: string | null;
}
/** Display name and symbol for a chain, or nulls when it is unrecognised. */
export function getNetworkMeta(chainId?: number | null): NetworkMeta {
if (chainId == null) return { name: null, symbol: null };
const known = knownNetworks[chainId];
if (known) return known;
return { name: findChain(chainId)?.name ?? null, symbol: null };
}
/**
* Block explorer base URL for a chain, or null when we have none.
*
* Derived from the configured chains rather than a second hardcoded map —
* viem's chain objects already carry `blockExplorers`. Null is a real answer:
* an address on an unrecognised chain does not exist in mainnet's address
* space, so callers render no link rather than a misleading one.
*/
export function getExplorerUrl(chainId?: number | null): string | null {
const chain = findChain(chainId);
return chain?.blockExplorers?.default.url ?? null;
}
/** A read client for `chainId`, if the app configured one. */
export function getConfiguredClient(
chainId: number
): OnchainReadClient | undefined {
return onchainConfig.getClient?.(chainId);
}
/** App-wide gateways for ENS and Basename avatar assets, if configured. */
export function getConfiguredAvatarGatewayUrls(): AssetGatewayUrls | undefined {
return onchainConfig.avatarGatewayUrls;
}