onchain-ui
uiaddress-identitysource: ok · hit
Address Identity
Resolves an EVM address to an ENS or Base name with optional avatar, copy-to-clipboard, and explorer link.
npx shadcn add "https://onchain-ui.dev/r/address-identity.json"free
Where it goes
kanban
Tags auto-generated by rules on 9/24/2026 — empty means no evidence, not lack of support.
Dependencies
Bundle weight · lighttoastssonner
iconslucide-react
Registry dependencies
@onchain-ui/sonner
Files5 · source cached 10/8/2026, 2:56:10 PM
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>
);
}
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/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;
}