shadimage
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 · light
toastssonner
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;
}

More from onchain-ui

Browse all components in this library →