onchain-ui
blockconnect-walletsource: ok · refreshed
Connect Wallet
A complete connect, identity, and disconnect surface wired to wagmi.
npx shadcn add "https://onchain-ui.dev/r/connect-wallet.json"free
No selection tags yet — empty means no evidence, not lack of support.
Dependencies
Bundle weight · lighttoastssonner
iconslucide-react
Registry dependencies
@onchain-ui/sonnertooltipdropdown-menu
Files11 · source cached 10/8/2026, 4:08:46 AM
registry/onchain-ui/connect-wallet.tsx
"use client";
import { useConnect, useConnection, useDisconnect } from "wagmi";
import { LogOutIcon, WalletIcon } from "lucide-react";
import { cn } from "@/lib/utils";
import { useAvailableConnectors } from "@/hooks/use-available-connectors";
import { AddressIdentity } from "./address-identity";
import { ConnectorList } from "./connector-list";
import { WalletButton } from "./wallet-button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
// Derived from the hook rather than wagmi's generic `Connector`: apps that
// augment `Register` get their own connector union, which the generic type is
// not assignable to.
type ProbedConnector = ReturnType<
typeof useAvailableConnectors
>["connectors"][number];
export interface ConnectWalletProps {
/** Label for the disconnected trigger. Default: "Connect wallet" */
label?: string;
/** Show the network mark on the connected pill. Default: true */
showNetwork?: boolean;
/** Visual size. Default: "default" */
size?: "sm" | "default";
/** Called when a connection attempt fails */
onError?: (error: Error) => void;
/** 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;
function toOption(connector: ProbedConnector) {
return {
id: connector.uid,
// wagmi names the legacy window.ethereum fallback "Injected" — wallet
// plumbing jargon nobody picks a wallet by.
name: connector.id === "injected" ? "Browser wallet" : connector.name,
icon: connector.icon,
};
}
/**
* Connect, show, and disconnect a wallet.
*
* This is the wired block: it owns the wagmi calls so the pieces it composes —
* `WalletButton`, `ConnectorList`, `AddressIdentity` — stay presentational and
* testable with plain data. Swap any of them without touching this file.
*/
export function ConnectWallet({
label = "Connect wallet",
showNetwork = true,
size = "default",
onError,
className,
contentClassName,
}: ConnectWalletProps) {
const { address, chainId, isConnected } = useConnection();
const connect = useConnect();
const disconnect = useDisconnect();
const { connectors, isProbing } = useAvailableConnectors();
const handleConnect = (connectorId: string) => {
const connector = connectors.find((entry) => entry.uid === connectorId);
if (!connector) return;
connect.mutate(
{ connector },
{ onError: (error) => onError?.(error) }
);
};
// `variables.connector` may be a bare CreateConnectorFn, which has no uid.
const pendingConnector = connect.isPending
? connect.variables?.connector
: undefined;
const pendingConnectorId =
pendingConnector && "uid" in pendingConnector ? pendingConnector.uid : null;
const triggerClassName = 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",
triggerSizes[size],
className
);
if (isConnected && address) {
return (
<DropdownMenu>
<DropdownMenuTrigger
render={
<WalletButton
address={address}
chainId={chainId}
showNetwork={showNetwork}
size={size}
className={className}
/>
}
/>
<DropdownMenuContent
align="end"
className={cn("w-56", contentClassName)}
>
<div className="px-2 py-1.5">
<AddressIdentity address={address} chainId={chainId} />
</div>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => disconnect.mutate()}>
<LogOutIcon />
Disconnect
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
}
// Exactly one wallet: a menu of one is a worse click than connecting.
if (!isProbing && connectors.length === 1) {
return (
<button
type="button"
disabled={connect.isPending}
onClick={() => handleConnect(connectors[0].uid)}
className={triggerClassName}
>
<WalletIcon className="size-4" aria-hidden="true" />
{connect.isPending ? "Connecting…" : label}
</button>
);
}
return (
<DropdownMenu>
<DropdownMenuTrigger
disabled={isProbing || connect.isPending}
className={triggerClassName}
>
<WalletIcon className="size-4" aria-hidden="true" />
{connect.isPending ? "Connecting…" : label}
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className={cn("w-60", contentClassName)}>
<ConnectorList
connectors={connectors.map(toOption)}
onSelect={handleConnect}
pendingId={pendingConnectorId}
/>
</DropdownMenuContent>
</DropdownMenu>
);
}
registry/onchain-ui/wallet-button.tsx
"use client";
import { ChevronDownIcon, LoaderCircleIcon } from "lucide-react";
import { cn } from "@/lib/utils";
import { AddressIdentity } from "./address-identity";
import { NetworkLogo } from "./network-logo";
import type { Address } from "viem";
import type { ComponentProps } from "react";
export interface WalletButtonProps
// `name` is the resolved account name, not the button's form name.
extends Omit<ComponentProps<"button">, "name"> {
/** Connected wallet address */
address: Address;
/** Connected chain id. Renders a network mark when set */
chainId?: number | null;
/** Resolved name override, e.g. vitalik.eth. Skips resolution when set */
name?: string | null;
/** Avatar URL override */
avatarUrl?: string | null;
/** Show a spinner in place of the chevron */
isPending?: boolean;
/** Show the network mark. Default: true */
showNetwork?: boolean;
/** Show the trailing chevron. Default: true */
showChevron?: boolean;
/** Visual size. Default: "default" */
size?: "sm" | "default";
}
const buttonSizes = {
sm: "h-7 gap-1.5 py-0.5 pr-1.5 pl-1",
default: "h-8 gap-2 py-1 pr-2 pl-1.5",
} as const;
const avatarSizes = {
sm: "size-5",
default: "size-6",
} as const;
/**
* The connected-wallet pill: identity, network, and a disclosure chevron.
*
* It renders a plain button and forwards every prop, so it drops straight into
* a menu trigger (`<DropdownMenuTrigger render={<WalletButton … />} />`) or
* takes an `onClick` of its own.
*/
export function WalletButton({
address,
chainId,
name,
avatarUrl,
isPending = false,
showNetwork = true,
showChevron = true,
size = "default",
className,
...props
}: WalletButtonProps) {
return (
<button
type="button"
className={cn(
"inline-flex shrink-0 items-center rounded-lg border border-border bg-background",
"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",
buttonSizes[size],
className
)}
{...props}
>
<AddressIdentity
address={address}
name={name}
avatarUrl={avatarUrl}
// The whole pill is the click target; the copy and explorer controls
// would be nested interactive elements inside it.
showCopy={false}
showExplorer={false}
avatarClassName={avatarSizes[size]}
className="pointer-events-none"
/>
{showNetwork && chainId != null && (
<NetworkLogo chainId={chainId} size="xs" />
)}
{isPending ? (
<LoaderCircleIcon
className="size-3.5 animate-spin text-muted-foreground"
aria-hidden="true"
/>
) : (
showChevron && (
<ChevronDownIcon
className="size-3.5 text-muted-foreground"
aria-hidden="true"
/>
)
)}
</button>
);
}
registry/onchain-ui/connector-list.tsx
"use client";
import { LoaderCircleIcon, WalletIcon } from "lucide-react";
import { cn } from "@/lib/utils";
import type { ReactNode } from "react";
export interface ConnectorOption {
/** Stable connector id, passed back to `onSelect` */
id: string;
/** Wallet name, e.g. "MetaMask" */
name: string;
/** Wallet icon. EIP-6963 wallets announce a data URI */
icon?: string | null;
/** Secondary line, e.g. "Installed" or "Scan with your phone" */
description?: ReactNode;
/** Render but do not allow selection */
disabled?: boolean;
}
export interface ConnectorListProps {
/** Wallets the user can pick from */
connectors: Array<ConnectorOption>;
/** Called with the id of the wallet the user picked */
onSelect: (connectorId: string) => void;
/** Connector currently being connected. Blocks further selection */
pendingId?: string | null;
/** Disable every row */
disabled?: boolean;
/** Shown when `connectors` is empty */
emptyMessage?: ReactNode;
/** Applied to the list wrapper */
className?: string;
/** Applied to each row */
itemClassName?: string;
}
function ConnectorIcon({ connector }: { connector: ConnectorOption }) {
if (connector.icon) {
return (
<img
src={connector.icon}
alt=""
className="size-5 shrink-0 rounded"
aria-hidden="true"
/>
);
}
return <WalletIcon className="size-5 shrink-0 text-muted-foreground" />;
}
export function ConnectorList({
connectors,
onSelect,
pendingId,
disabled = false,
emptyMessage = "No compatible wallet connection is available on this device.",
className,
itemClassName,
}: ConnectorListProps) {
if (connectors.length === 0) {
return (
<p
className={cn(
"px-2 py-6 text-center text-xs text-balance text-muted-foreground",
className
)}
>
{emptyMessage}
</p>
);
}
const isPending = pendingId != null;
return (
<div className={cn("grid gap-0.5", className)}>
{connectors.map((connector) => (
<button
key={connector.id}
type="button"
disabled={disabled || connector.disabled || isPending}
onClick={() => onSelect(connector.id)}
className={cn(
"flex w-full items-center gap-2.5 rounded-lg px-2 py-2 text-left text-sm",
"transition-colors duration-150 outline-none",
"hover:bg-accent focus-visible:ring-2 focus-visible:ring-ring",
"disabled:pointer-events-none disabled:opacity-50",
itemClassName
)}
>
<ConnectorIcon connector={connector} />
<span className="min-w-0 flex-1">
<span className="block truncate font-medium">{connector.name}</span>
{connector.description && (
<span className="block truncate text-xs text-muted-foreground">
{connector.description}
</span>
)}
</span>
{pendingId === connector.id && (
<LoaderCircleIcon
className="size-4 shrink-0 animate-spin text-muted-foreground"
aria-hidden="true"
/>
)}
</button>
))}
</div>
);
}
registry/onchain-ui/address-identity.tsx
"use client";
import { useEffect, useMemo, useRef, useState } from "react";
import { cn } from "@/lib/utils";
import { truncateAddress } from "@/lib/onchain/format";
import {
resolveOnchainIdentity,
resolveNameAvatar,
type OnchainIdentity,
type OnchainResolverOptions,
} from "@/lib/onchain/resolvers";
import {
AddressDisplay,
type AddressDisplayProps,
} from "./address-display";
import type { Address } from "viem";
export interface AddressIdentityProps
extends Omit<AddressDisplayProps, "label" | "address"> {
/** EVM wallet address */
address: Address;
/** Resolved name override, e.g. vitalik.eth or alice.base.eth */
name?: string | null;
/** Avatar URL override */
avatarUrl?: string | null;
/** Resolve ENS/Base name and avatar. Default: true */
resolveIdentity?: boolean;
/** Resolver client and lookup overrides */
resolverOptions?: OnchainResolverOptions;
/** Show avatar when available. Default: true */
showAvatar?: boolean;
/** Applied to the root wrapper */
className?: string;
/** Applied to the avatar */
avatarClassName?: string;
/** Applied to the identity text group */
contentClassName?: string;
}
export function AddressIdentity({
address,
name,
avatarUrl,
resolveIdentity = true,
resolverOptions,
showAvatar = true,
className,
avatarClassName,
contentClassName,
truncate = true,
truncateChars = 4,
...addressDisplayProps
}: AddressIdentityProps) {
const identityKey = `${address}:${name ?? ""}:${avatarUrl ?? ""}:${resolveIdentity}`;
const [resolvedIdentity, setResolvedIdentity] = useState<{
key: string;
identity: OnchainIdentity;
} | null>(null);
const [avatarFailure, setAvatarFailure] = useState<{
src: string;
attempts: number;
} | null>(null);
// Read resolver options through a ref so inline object literals don't
// retrigger resolution on every render.
const resolverOptionsRef = useRef(resolverOptions);
useEffect(() => {
resolverOptionsRef.current = resolverOptions;
});
useEffect(() => {
if (!resolveIdentity || (name && avatarUrl)) return;
let isMounted = true;
const options = resolverOptionsRef.current;
const identityPromise = name
? resolveNameAvatar(name, options).then((avatar) => ({
address,
name,
avatar,
source: name.endsWith(".base.eth") ? "basename" : "ens",
}) satisfies OnchainIdentity)
: resolveOnchainIdentity(address, options);
identityPromise
.then((result) => {
if (isMounted) {
setResolvedIdentity({ key: identityKey, identity: result });
}
});
return () => {
isMounted = false;
};
}, [address, avatarUrl, identityKey, name, resolveIdentity]);
const activeIdentity =
resolvedIdentity?.key === identityKey ? resolvedIdentity.identity : null;
const displayName = name ?? activeIdentity?.name ?? null;
const resolvedAvatar = avatarUrl ?? activeIdentity?.avatar ?? null;
const displayAvatar =
resolvedAvatar !== null &&
!(avatarFailure?.src === resolvedAvatar && avatarFailure.attempts >= 2)
? resolvedAvatar
: null;
const isLoading =
resolveIdentity && !(name && avatarUrl) && activeIdentity === null;
const fallbackLabel = truncate
? truncateAddress(address, truncateChars)
: address;
const label = displayName ?? fallbackLabel;
const avatarFallback = useMemo(() => {
if (displayName) return displayName.slice(0, 2).toUpperCase();
return address.slice(2, 4).toUpperCase();
}, [address, displayName]);
return (
<div className={cn("flex min-w-0 items-center gap-2", className)}>
{showAvatar && (
<span
className={cn(
"flex size-7 shrink-0 items-center justify-center overflow-hidden rounded-full border bg-muted text-[10px] font-medium text-muted-foreground",
isLoading && "animate-pulse",
avatarClassName
)}
aria-hidden="true"
>
{displayAvatar ? (
<img
key={`${displayAvatar}:${avatarFailure?.src === displayAvatar ? avatarFailure.attempts : 0}`}
src={displayAvatar}
alt=""
className="size-full object-cover"
referrerPolicy="no-referrer"
onError={() =>
setAvatarFailure((failure) => ({
src: displayAvatar,
attempts:
failure?.src === displayAvatar ? failure.attempts + 1 : 1,
}))
}
/>
) : (
avatarFallback
)}
</span>
)}
<AddressDisplay
address={address}
label={label}
truncate={truncate}
truncateChars={truncateChars}
className={contentClassName}
{...addressDisplayProps}
/>
</div>
);
}
registry/onchain-ui/address-display.tsx
"use client";
import { toast } from "sonner";
import { CopyIcon, ExternalLinkIcon } from "lucide-react";
import { cn } from "@/lib/utils";
import { truncateAddress } from "@/lib/onchain/format";
import { getExplorerUrl } from "@/lib/onchain/config";
import type { Address } from "viem";
import type { ReactNode } from "react";
export interface AddressDisplayProps {
/** EVM wallet address */
address: Address;
/** Display label. The copied value is always the underlying address. */
label?: ReactNode;
/** Applied to the root wrapper */
className?: string;
/** Applied to the address text or copy trigger */
addressClassName?: string;
/** Truncate the address. Default: true */
truncate?: boolean;
/** Number of characters to show at each end when truncated. Default: 4 */
truncateChars?: number;
/** Show copy-to-clipboard button. Default: true */
showCopy?: boolean;
/** Show block explorer link. Default: true */
showExplorer?: boolean;
/** EVM chain id used to pick a known block explorer. Default: 1 (Etherscan) */
chainId?: number | null;
/** Full explorer URL override e.g. "https://basescan.org/address/0x..." */
explorerUrl?: string;
/** Icon rendered before the address when copy is enabled */
copyIcon?: ReactNode;
/** Icon rendered for the explorer link */
explorerIcon?: ReactNode;
}
export function AddressDisplay({
address,
label,
className,
addressClassName,
truncate = true,
truncateChars = 4,
showCopy = true,
showExplorer = true,
chainId,
explorerUrl,
copyIcon = <CopyIcon className="size-3 shrink-0" />,
explorerIcon = <ExternalLinkIcon className="size-3 shrink-0" />,
}: AddressDisplayProps) {
const handleCopy = async () => {
try {
await navigator.clipboard.writeText(address);
toast.success("Address copied");
} catch {
toast.error("Could not copy address");
}
};
// No chain id means "assume mainnet", the documented default. A chain id we
// do not recognise is different: linking it to Etherscan would point at a
// page about a different chain's address space. Drop the link instead and
// let `explorerUrl` cover the long tail.
const explorerBase = getExplorerUrl(chainId ?? 1);
const href =
explorerUrl ??
(explorerBase ? `${explorerBase}/address/${address}` : null);
const displayLabel =
label ?? (truncate ? truncateAddress(address, truncateChars) : address);
const labelClassName = truncate ? "whitespace-nowrap" : "break-all";
return (
<div className={cn("flex min-w-0 items-center gap-1.5", className)}>
{showCopy ? (
<button
type="button"
onClick={handleCopy}
title="Copy address"
className={cn(
"flex min-w-0 items-center gap-1.5 font-mono text-xs text-muted-foreground",
"hover:text-foreground cursor-copy transition-colors duration-150",
addressClassName
)}
>
{copyIcon}
<span className={labelClassName}>{displayLabel}</span>
</button>
) : (
<span
className={cn(
"min-w-0 font-mono text-xs text-muted-foreground",
labelClassName,
addressClassName
)}
>
{displayLabel}
</span>
)}
{showExplorer && href && (
<a
href={href}
target="_blank"
rel="noopener noreferrer"
title="View on explorer"
className="text-muted-foreground hover:text-foreground transition-colors duration-150"
>
{explorerIcon}
</a>
)}
</div>
);
}
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>
);
}
hooks/use-available-connectors.ts
"use client";
import { useEffect, useState } from "react";
import { useConnectors } from "wagmi";
/**
* Filters injected connectors down to wallets present in the browser while
* preserving remote connectors such as MetaMask Connect and WalletConnect.
*
* `useConnectors()` always includes the generic `injected()` fallback even when
* no wallet is installed, and connecting with it throws ProviderNotFoundError.
* Only injected connectors should be probed: remote connectors create their
* provider as part of connecting and are essential for mobile app deep links.
*
* The return type is inferred on purpose. Apps that augment wagmi's `Register`
* get `useConnectors()` narrowed to their own connector union, which is not
* assignable to the generic `Connector`; annotating this as `Connector[]` would
* compile here and break in those apps.
*/
export function useAvailableConnectors() {
const connectors = useConnectors();
const [probed, setProbed] = useState<{
key: string;
connectors: Array<(typeof connectors)[number]>;
} | null>(null);
// Connector identity, not array identity: wagmi may hand back a new array on
// any store update, and probing on every render would loop.
const connectorKey = connectors.map((connector) => connector.uid).join(",");
useEffect(() => {
let cancelled = false;
Promise.all(
connectors.map(async (connector) => {
if (connector.type !== "injected") return { connector, provider: null };
try {
const provider = await connector.getProvider();
return provider ? { connector, provider } : null;
} catch {
return null;
}
})
).then((results) => {
if (cancelled) return;
const found = results.filter((result) => result !== null);
// EIP-6963 announces injected wallets by name; the generic "Injected"
// fallback points at the same provider, so it is only kept when it is
// the sole way to reach that wallet.
const hasNamedInjected = found.some(
({ connector }) =>
connector.type === "injected" && connector.id !== "injected"
);
// The MetaMask SDK connector replaces the extension's EIP-6963
// announcement, so no named injected wallet is left to trigger the
// dedupe above — yet the fallback still finds MetaMask's provider and
// would list the same wallet twice.
const hasMetaMaskConnector = found.some(
({ connector }) => connector.type === "metaMask"
);
const isDuplicateFallback = ({
connector,
provider,
}: (typeof found)[number]) =>
connector.id === "injected" &&
(hasNamedInjected ||
(hasMetaMaskConnector &&
(provider as { isMetaMask?: boolean } | null)?.isMetaMask ===
true));
setProbed({
key: connectorKey,
connectors: found
.filter((result) => !isDuplicateFallback(result))
.map(({ connector }) => connector),
});
});
return () => {
cancelled = true;
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [connectorKey]);
// Keying the result rather than tracking a separate flag means a connector
// change reports "probing" again instead of serving the previous browser's
// wallet list for a frame.
const isCurrent = probed?.key === connectorKey;
return {
connectors: isCurrent ? probed.connectors : [],
isProbing: !isCurrent,
};
}
lib/onchain/format.ts
export function truncateAddress(address: string, chars = 4): string {
if (!address) return "";
return `${address.slice(0, chars + 2)}...${address.slice(-chars)}`;
}
export type NumericValue = number | string | null | undefined;
/** Parses numbers and numeric strings; anything else becomes null. */
export function parseNumericValue(value: NumericValue): number | null {
if (typeof value === "number") return Number.isFinite(value) ? value : null;
if (typeof value === "string" && value.trim() !== "") {
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : null;
}
return null;
}
lib/onchain/resolvers.ts
import {
createPublicClient,
encodePacked,
fallback,
http,
isAddress,
keccak256,
namehash,
zeroAddress,
type Address,
type AssetGatewayUrls,
type Hex,
type PublicClient,
} from "viem";
import { normalize, parseAvatarRecord } from "viem/ens";
import { base, mainnet } from "viem/chains";
import {
getConfiguredAvatarGatewayUrls,
getConfiguredClient,
type OnchainReadClient,
} from "./config";
export const BASENAME_L2_RESOLVER_ADDRESS =
"0xC6d566A56A1aFf6508b41f6c90ff131615583BCD";
const baseNamesAbi = [
{
inputs: [{ internalType: "bytes32", name: "node", type: "bytes32" }],
name: "name",
outputs: [{ internalType: "string", name: "", type: "string" }],
stateMutability: "view",
type: "function",
},
{
inputs: [
{ internalType: "bytes32", name: "node", type: "bytes32" },
{ internalType: "string", name: "key", type: "string" },
],
name: "text",
outputs: [{ internalType: "string", name: "", type: "string" }],
stateMutability: "view",
type: "function",
},
{
inputs: [{ internalType: "bytes32", name: "node", type: "bytes32" }],
name: "addr",
outputs: [{ internalType: "address", name: "", type: "address" }],
stateMutability: "view",
type: "function",
},
] as const;
export interface OnchainResolverOptions {
mainnetClient?: OnchainReadClient;
baseClient?: OnchainReadClient;
basenameResolverAddress?: Address;
avatarGatewayUrls?: AssetGatewayUrls;
reverseLookupOrder?: Array<"ens" | "basename">;
}
export interface OnchainIdentity {
address: Address;
name: string | null;
avatar: string | null;
source: "ens" | "basename" | null;
}
// The default clients use each chain's public RPC endpoint, with a fallback:
// viem's default mainnet endpoint (eth.merkle.io) aggressively rate limits
// some IPs, which would otherwise silently break name resolution. Public
// endpoints are fine for demos; pass your own clients through
// `OnchainResolverOptions` in production.
function createDefaultMainnetClient() {
return createPublicClient({
chain: mainnet,
transport: fallback([
http(),
http("https://ethereum-rpc.publicnode.com"),
]),
});
}
function createDefaultBaseClient() {
return createPublicClient({
chain: base,
transport: fallback([http(), http("https://base-rpc.publicnode.com")]),
});
}
let defaultMainnetClient:
| ReturnType<typeof createDefaultMainnetClient>
| undefined;
let defaultBaseClient: ReturnType<typeof createDefaultBaseClient> | undefined;
// Precedence is per-instance, then app-wide, then the public fallback above.
// The middle step is what stops an app that already configured RPC endpoints
// (in wagmi, say) from having to configure them a second time here.
function getMainnetClient(options?: OnchainResolverOptions) {
if (options?.mainnetClient) return options.mainnetClient;
const configured = getConfiguredClient(mainnet.id);
if (configured) return configured;
if (!defaultMainnetClient) defaultMainnetClient = createDefaultMainnetClient();
return defaultMainnetClient;
}
function getBaseClient(options?: OnchainResolverOptions) {
if (options?.baseClient) return options.baseClient;
const configured = getConfiguredClient(base.id);
if (configured) return configured;
if (!defaultBaseClient) defaultBaseClient = createDefaultBaseClient();
return defaultBaseClient;
}
function getBasenameResolverAddress(options?: OnchainResolverOptions) {
return options?.basenameResolverAddress ?? BASENAME_L2_RESOLVER_ADDRESS;
}
function getAvatarGatewayUrls(options?: OnchainResolverOptions) {
return (
options?.avatarGatewayUrls ??
getConfiguredAvatarGatewayUrls() ?? {
ipfs: "https://gateway.pinata.cloud",
arweave: "https://arweave.net",
}
);
}
export function convertChainIdToCoinType(chainId: number): string {
if (chainId === mainnet.id) return "addr";
const coinType = (0x80000000 | chainId) >>> 0;
return coinType.toString(16).toUpperCase();
}
export function convertReverseNodeToBytes(address: Address, chainId: number) {
const addressFormatted = address.toLowerCase() as Address;
const addressNode = keccak256(addressFormatted.substring(2) as Hex);
const chainCoinType = convertChainIdToCoinType(chainId);
const baseReverseNode = namehash(`${chainCoinType}.reverse`);
return keccak256(
encodePacked(["bytes32", "bytes32"], [baseReverseNode, addressNode])
);
}
// Internal lookups report failures instead of collapsing them into null, so
// callers can tell "no record exists" (cacheable) from "the RPC call failed"
// (must not be cached). The exported resolve* functions keep returning null
// for both.
type LookupResult<T> = { value: T; failed: boolean };
async function lookupEnsName(
address: Address,
options?: OnchainResolverOptions
): Promise<LookupResult<string | null>> {
try {
const name = await getMainnetClient(options).getEnsName({ address });
return { value: name, failed: false };
} catch {
return { value: null, failed: true };
}
}
async function lookupBasename(
address: Address,
options?: OnchainResolverOptions
): Promise<LookupResult<string | null>> {
try {
const reverseNode = convertReverseNodeToBytes(address, base.id);
const name = await getBaseClient(options).readContract({
abi: baseNamesAbi,
address: getBasenameResolverAddress(options),
functionName: "name",
args: [reverseNode],
});
return { value: name || null, failed: false };
} catch {
return { value: null, failed: true };
}
}
export async function resolveEnsName(
address: Address,
options?: OnchainResolverOptions
) {
return (await lookupEnsName(address, options)).value;
}
export async function resolveBasename(
address: Address,
options?: OnchainResolverOptions
) {
return (await lookupBasename(address, options)).value;
}
// Session-level caches so repeated lookups (portfolio rows, remounts) don't
// refetch. Keyed lookups share in-flight promises, deduping concurrent calls.
// Failed lookups are evicted once settled so transient RPC errors are retried
// instead of pinning "no result" for the whole session.
//
// Caches are scoped per resolver config: results from different clients or a
// different Basename resolver must not mix. Scopes are keyed on the client
// instances via WeakMap so a custom client keeps request deduplication, and
// short-lived clients get garbage collected along with their cache.
type ResolverCaches = {
avatar: Map<string, Promise<LookupResult<string | null>>>;
identity: Map<string, Promise<LookupResult<OnchainIdentity>>>;
};
const defaultMainnetScope = {};
const defaultBaseScope = {};
const cacheScopes = new WeakMap<
object,
WeakMap<object, Map<string, ResolverCaches>>
>();
function getResolverCaches(options?: OnchainResolverOptions): ResolverCaches {
const mainnetScope = options?.mainnetClient ?? defaultMainnetScope;
const baseScope = options?.baseClient ?? defaultBaseScope;
const resolverAddress = getBasenameResolverAddress(options).toLowerCase();
const gatewayUrls = getAvatarGatewayUrls(options);
const resolverConfigKey = [
resolverAddress,
gatewayUrls.ipfs ?? "",
gatewayUrls.arweave ?? "",
].join(":");
let baseScopes = cacheScopes.get(mainnetScope);
if (!baseScopes) {
baseScopes = new WeakMap();
cacheScopes.set(mainnetScope, baseScopes);
}
let resolverScopes = baseScopes.get(baseScope);
if (!resolverScopes) {
resolverScopes = new Map();
baseScopes.set(baseScope, resolverScopes);
}
let caches = resolverScopes.get(resolverConfigKey);
if (!caches) {
caches = { avatar: new Map(), identity: new Map() };
resolverScopes.set(resolverConfigKey, caches);
}
return caches;
}
export async function resolveNameAvatar(
name: string,
options?: OnchainResolverOptions
) {
return (await lookupNameAvatarWithRetry(name, options)).value;
}
/** Retry one transient RPC failure before exposing the avatar as unavailable. */
async function lookupNameAvatarWithRetry(
name: string,
options?: OnchainResolverOptions
): Promise<LookupResult<string | null>> {
const first = await lookupNameAvatarCached(name, options);
if (!first.failed) return first;
// Failed cached lookups evict themselves, so this is a fresh RPC attempt.
return lookupNameAvatarCached(name, options);
}
async function lookupNameAvatarCached(
name: string,
options?: OnchainResolverOptions
): Promise<LookupResult<string | null>> {
const cache = getResolverCaches(options).avatar;
const cached = cache.get(name);
if (cached) return cached;
const promise = lookupNameAvatar(name, options);
cache.set(name, promise);
const result = await promise;
if (result.failed) cache.delete(name);
return result;
}
async function lookupNameAvatar(
name: string,
options?: OnchainResolverOptions
): Promise<LookupResult<string | null>> {
try {
if (name.endsWith(".base.eth")) {
// The Basename L2 resolver is a plain resolver, not an ENS universal
// resolver, so viem's getEnsAvatar reverts against it. Read the avatar
// text record directly instead.
const client = getBaseClient(options);
const record = await client.readContract({
abi: baseNamesAbi,
address: getBasenameResolverAddress(options),
functionName: "text",
args: [namehash(normalize(name)), "avatar"],
});
if (!record) return { value: null, failed: false };
return {
// parseAvatarRecord only consumes read actions, but viem types it as a
// full client. The configured seam deliberately accepts less.
value: await parseAvatarRecord(client as PublicClient, {
record,
gatewayUrls: getAvatarGatewayUrls(options),
}),
failed: false,
};
}
const avatar = await getMainnetClient(options).getEnsAvatar({
name,
assetGatewayUrls: getAvatarGatewayUrls(options),
});
return { value: avatar, failed: false };
} catch {
return { value: null, failed: true };
}
}
export async function resolveAddress(
input: string,
options?: OnchainResolverOptions
): Promise<Address | null> {
if (isAddress(input)) return input;
try {
if (input.endsWith(".base.eth")) {
// Direct resolver read; see fetchNameAvatar for why getEnsAddress with
// a universalResolverAddress override does not work for Basenames.
const address = await getBaseClient(options).readContract({
abi: baseNamesAbi,
address: getBasenameResolverAddress(options),
functionName: "addr",
args: [namehash(normalize(input))],
});
return address && address !== zeroAddress ? address : null;
}
if (input.endsWith(".eth")) {
return await getMainnetClient(options).getEnsAddress({ name: input });
}
} catch {
return null;
}
return null;
}
export async function resolveOnchainIdentity(
address: Address,
options?: OnchainResolverOptions
): Promise<OnchainIdentity> {
const reverseLookupOrder = options?.reverseLookupOrder ?? [
"basename",
"ens",
];
const cache = getResolverCaches(options).identity;
const cacheKey = `${address.toLowerCase()}:${reverseLookupOrder.join(",")}`;
const cached = cache.get(cacheKey);
if (cached) return (await cached).value;
const promise = lookupOnchainIdentity(address, reverseLookupOrder, options);
cache.set(cacheKey, promise);
const result = await promise;
if (result.failed) cache.delete(cacheKey);
return result.value;
}
async function lookupOnchainIdentity(
address: Address,
reverseLookupOrder: Array<"ens" | "basename">,
options?: OnchainResolverOptions
): Promise<LookupResult<OnchainIdentity>> {
let failed = false;
for (const source of reverseLookupOrder) {
const name =
source === "basename"
? await lookupBasename(address, options)
: await lookupEnsName(address, options);
if (name.failed) failed = true;
if (name.value) {
const avatar = await lookupNameAvatarWithRetry(name.value, options);
return {
value: {
address,
name: name.value,
avatar: avatar.value,
source,
},
failed: failed || avatar.failed,
};
}
}
return {
value: {
address,
name: null,
avatar: null,
source: null,
},
failed,
};
}
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,
robinhood,
} 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, robinhood],
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" },
4663: { name: "Robinhood Chain", symbol: "RH" },
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;
}