{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "token-list",
  "title": "Token List",
  "author": "Satchmo",
  "description": "Displays a list of BSV20/BSV21 fungible token holdings with balances, icons, and token details fetched from the 1Sat API",
  "dependencies": [
    "lucide-react"
  ],
  "registryDependencies": [
    "card",
    "badge",
    "skeleton",
    "avatar",
    "separator"
  ],
  "files": [
    {
      "path": "registry/new-york/blocks/token-list/index.tsx",
      "content": "\"use client\"\n\nimport { TokenListUI } from \"./token-list-ui\"\nimport {\n  useTokenList,\n  type UseTokenListOptions,\n  type TokenHolding,\n  type TokenProtocol,\n} from \"./use-token-list\"\n\n// ---------------------------------------------------------------------------\n// Re-exports\n// ---------------------------------------------------------------------------\n\nexport { TokenListUI, type TokenListUIProps } from \"./token-list-ui\"\nexport {\n  useTokenList,\n  type UseTokenListOptions,\n  type UseTokenListReturn,\n  type TokenHolding,\n  type TokenType,\n  type TokenProtocol,\n} from \"./use-token-list\"\n\n// ---------------------------------------------------------------------------\n// Types\n// ---------------------------------------------------------------------------\n\nexport interface TokenListProps {\n  /** BSV payment address to fetch token holdings for */\n  address: string | null\n  /** Token IDs to fetch balances for */\n  tokenIds?: string[]\n  /** Pre-populated token holdings (skips API fetch for tokens with matching IDs) */\n  tokens?: TokenHolding[]\n  /** Custom API base URL (default: https://api.1sat.app) */\n  apiUrl?: string\n  /** ORDFS base URL for icon resolution (default: https://ordfs.network) */\n  ordfsBase?: string\n  /** Filter by protocol type (default: \"all\") */\n  protocol?: TokenProtocol\n  /** Whether to auto-fetch on mount (default: true) */\n  autoFetch?: boolean\n  /** Callback when a token row is selected */\n  onSelect?: (token: TokenHolding) => void\n  /** Callback when an external link action is triggered. Receives the URL string. */\n  onExternalLink?: (url: string) => void\n  /** Number of skeleton rows to show while loading (default: 3) */\n  skeletonCount?: number\n  /** Optional CSS class name */\n  className?: string\n}\n\n// ---------------------------------------------------------------------------\n// Composed component\n// ---------------------------------------------------------------------------\n\n/**\n * Displays a list of BSV20/BSV21 fungible token holdings with balances,\n * icons, and token details. Composes the `useTokenList` hook with the\n * `TokenListUI` presentation component.\n *\n * Requires a BSV address and a list of token IDs. Token details and balances\n * are fetched from the 1Sat API.\n *\n * @example\n * ```tsx\n * import { TokenList } from \"@/components/blocks/token-list\"\n *\n * function WalletTokens() {\n *   return (\n *     <TokenList\n *       address=\"1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa\"\n *       tokenIds={[\"abc123_0\", \"def456_0\"]}\n *       onSelect={(token) => console.log(\"Selected:\", token.symbol)}\n *     />\n *   )\n * }\n * ```\n */\nexport function TokenList({\n  address,\n  tokenIds,\n  tokens: prePopulated,\n  apiUrl,\n  ordfsBase,\n  protocol,\n  autoFetch,\n  onSelect,\n  onExternalLink,\n  skeletonCount,\n  className,\n}: TokenListProps) {\n  const { tokens, isLoading, error } = useTokenList({\n    address,\n    tokenIds,\n    tokens: prePopulated,\n    apiUrl,\n    ordfsBase,\n    protocol,\n    autoFetch,\n  })\n\n  return (\n    <TokenListUI\n      tokens={tokens}\n      isLoading={isLoading}\n      error={error}\n      onSelect={onSelect}\n      onExternalLink={onExternalLink}\n      skeletonCount={skeletonCount}\n      className={className}\n    />\n  )\n}\n",
      "type": "registry:block",
      "target": "~/components/blocks/token-list/index.tsx"
    },
    {
      "path": "registry/new-york/blocks/token-list/token-list-ui.tsx",
      "content": "\"use client\"\n\nimport { useCallback } from \"react\"\nimport { Coins, ExternalLink } from \"lucide-react\"\nimport { cn } from \"@/lib/utils\"\nimport { Button } from \"@/components/ui/button\"\nimport { Card, CardContent } from \"@/components/ui/card\"\nimport { Badge } from \"@/components/ui/badge\"\nimport { Skeleton } from \"@/components/ui/skeleton\"\nimport { Avatar, AvatarFallback, AvatarImage } from \"@/components/ui/avatar\"\nimport { Separator } from \"@/components/ui/separator\"\nimport type { TokenHolding, TokenProtocol, TokenType } from \"./use-token-list\"\n\n// ---------------------------------------------------------------------------\n// Types\n// ---------------------------------------------------------------------------\n\nexport interface TokenListUIProps {\n  /** Token holdings to display */\n  tokens: TokenHolding[]\n  /** Whether the list is loading */\n  isLoading: boolean\n  /** Error from the last fetch attempt */\n  error: Error | null\n  /** Callback when a token row is selected */\n  onSelect?: (token: TokenHolding) => void\n  /** Callback when an external link action is triggered (e.g. view on explorer). Receives the URL string. */\n  onExternalLink?: (url: string) => void\n  /** Filter displayed tokens by protocol type (default: \"all\"). Useful when tokens are pre-populated. */\n  protocol?: TokenProtocol\n  /** Number of skeleton rows to show while loading (default: 3) */\n  skeletonCount?: number\n  /** Optional CSS class name */\n  className?: string\n}\n\n// ---------------------------------------------------------------------------\n// Helpers\n// ---------------------------------------------------------------------------\n\n/**\n * Format a raw token amount with the given decimal precision.\n *\n * For example, amount \"1500000\" with decimals 4 becomes \"150.0000\".\n */\nfunction formatTokenAmount(amount: string, decimals: number): string {\n  if (decimals === 0) {\n    return BigInt(amount).toLocaleString()\n  }\n\n  const raw = amount.padStart(decimals + 1, \"0\")\n  const intPart = raw.slice(0, -decimals) || \"0\"\n  const decPart = raw.slice(-decimals)\n\n  // Format integer part with locale grouping\n  const formattedInt = BigInt(intPart).toLocaleString()\n\n  // Trim trailing zeros from decimal part, keep at least 2 digits\n  const trimmed = decPart.replace(/0+$/, \"\")\n  const displayDec = trimmed.length < 2 ? decPart.slice(0, 2) : trimmed\n\n  return `${formattedInt}.${displayDec}`\n}\n\n/** Badge variant based on token type */\nfunction typeBadgeVariant(\n  type: TokenType\n): \"default\" | \"secondary\" | \"outline\" {\n  return type === \"BSV21\" ? \"default\" : \"secondary\"\n}\n\n/** Truncate a token ID for display */\nfunction truncateId(id: string, maxLen = 16): string {\n  if (id.length <= maxLen) return id\n  return `${id.slice(0, 8)}...${id.slice(-6)}`\n}\n\n// ---------------------------------------------------------------------------\n// Sub-components\n// ---------------------------------------------------------------------------\n\ninterface TokenRowProps {\n  token: TokenHolding\n  onSelect?: (token: TokenHolding) => void\n  onExternalLink?: (url: string) => void\n  isLast: boolean\n}\n\nfunction TokenRow({ token, onSelect, onExternalLink, isLast }: TokenRowProps) {\n  const handleClick = useCallback(() => {\n    onSelect?.(token)\n  }, [onSelect, token])\n\n  const handleKeyDown = useCallback(\n    (e: React.KeyboardEvent) => {\n      if (e.key === \"Enter\" || e.key === \" \") {\n        e.preventDefault()\n        onSelect?.(token)\n      }\n    },\n    [onSelect, token]\n  )\n\n  const handleExternalLink = useCallback(\n    (e: React.MouseEvent) => {\n      e.stopPropagation()\n      const url = `https://whatsonchain.com/tx/${token.tokenId.split(\"_\")[0]}`\n      onExternalLink?.(url)\n    },\n    [onExternalLink, token.tokenId]\n  )\n\n  const isInteractive = !!onSelect\n\n  return (\n    <>\n      <div\n        role={isInteractive ? \"button\" : undefined}\n        tabIndex={isInteractive ? 0 : undefined}\n        className={cn(\n          \"flex items-center gap-4 px-4 py-3\",\n          isInteractive &&\n            \"cursor-pointer transition-colors hover:bg-muted/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 rounded-md\"\n        )}\n        onClick={isInteractive ? handleClick : undefined}\n        onKeyDown={isInteractive ? handleKeyDown : undefined}\n        aria-label={\n          isInteractive\n            ? `Select ${token.symbol} token, balance ${formatTokenAmount(token.balance, token.decimals)}`\n            : undefined\n        }\n      >\n        {/* Token icon */}\n        <Avatar className=\"size-10 flex-shrink-0\">\n          {token.iconUrl && (\n            <AvatarImage\n              src={token.iconUrl}\n              alt={`${token.symbol} icon`}\n            />\n          )}\n          <AvatarFallback className=\"bg-muted text-muted-foreground text-xs font-medium\">\n            {token.symbol.slice(0, 2).toUpperCase()}\n          </AvatarFallback>\n        </Avatar>\n\n        {/* Symbol + balance */}\n        <div className=\"flex-1 min-w-0\">\n          <div className=\"flex items-center gap-2\">\n            <span className=\"font-semibold text-sm text-foreground truncate\">\n              {token.symbol}\n            </span>\n            <Badge\n              variant={typeBadgeVariant(token.type)}\n              className=\"text-[10px] px-1.5 py-0 h-5\"\n            >\n              {token.type}\n            </Badge>\n          </div>\n          <p className=\"text-sm text-muted-foreground tabular-nums\">\n            {formatTokenAmount(token.balance, token.decimals)}\n          </p>\n        </div>\n\n        {/* Token ID + external link */}\n        <div className=\"flex items-center gap-2\">\n          <span className=\"text-xs text-muted-foreground font-mono truncate max-w-[120px] hidden sm:block\">\n            {truncateId(token.tokenId)}\n          </span>\n          {onExternalLink && (\n            <Button\n              variant=\"ghost\"\n              size=\"icon\"\n              className=\"size-7 flex-shrink-0\"\n              onClick={handleExternalLink}\n              aria-label={`View ${token.symbol} on explorer`}\n            >\n              <ExternalLink data-icon className=\"size-3.5 text-muted-foreground\" />\n            </Button>\n          )}\n        </div>\n      </div>\n      {!isLast && <Separator />}\n    </>\n  )\n}\n\nfunction SkeletonRow({ isLast }: { isLast: boolean }) {\n  return (\n    <>\n      <div className=\"flex items-center gap-4 px-4 py-3\">\n        <Skeleton className=\"size-10 rounded-full flex-shrink-0\" />\n        <div className=\"flex-1 flex flex-col gap-2\">\n          <div className=\"flex items-center gap-2\">\n            <Skeleton className=\"h-4 w-20\" />\n            <Skeleton className=\"h-5 w-12 rounded-full\" />\n          </div>\n          <Skeleton className=\"h-3.5 w-28\" />\n        </div>\n        <Skeleton className=\"h-3 w-24 hidden sm:block\" />\n      </div>\n      {!isLast && <Separator />}\n    </>\n  )\n}\n\n// ---------------------------------------------------------------------------\n// Main UI\n// ---------------------------------------------------------------------------\n\n/**\n * Pure presentation component for a list of fungible token holdings.\n *\n * Renders loading skeletons, an empty state, an error state, or the\n * token list rows. All data and callbacks are provided via props.\n */\nexport function TokenListUI({\n  tokens,\n  isLoading,\n  error,\n  onSelect,\n  onExternalLink,\n  protocol = \"all\",\n  skeletonCount = 3,\n  className,\n}: TokenListUIProps) {\n  // Apply client-side protocol filter when tokens are pre-populated\n  const filteredTokens =\n    protocol === \"all\"\n      ? tokens\n      : tokens.filter((t) => t.type.toLowerCase() === protocol)\n\n  // Loading state\n  if (isLoading && filteredTokens.length === 0) {\n    return (\n      <Card className={cn(\"w-full\", className)}>\n        <CardContent className=\"p-0\">\n          {Array.from({ length: skeletonCount }, (_, i) => (\n            <SkeletonRow\n              key={`skeleton-${i}`}\n              isLast={i === skeletonCount - 1}\n            />\n          ))}\n        </CardContent>\n      </Card>\n    )\n  }\n\n  // Error state\n  if (error) {\n    return (\n      <Card className={cn(\"w-full\", className)}>\n        <CardContent className=\"flex flex-col items-center gap-2 py-10\">\n          <Coins\n            className=\"size-10 text-destructive/60\"\n            aria-hidden=\"true\"\n          />\n          <p className=\"text-sm font-medium text-destructive\">\n            Failed to load tokens\n          </p>\n          <p className=\"text-xs text-muted-foreground\">{error.message}</p>\n        </CardContent>\n      </Card>\n    )\n  }\n\n  // Empty state\n  if (filteredTokens.length === 0) {\n    return (\n      <Card className={cn(\"w-full\", className)}>\n        <CardContent className=\"flex flex-col items-center gap-2 py-10\">\n          <Coins\n            className=\"size-10 text-muted-foreground/50\"\n            aria-hidden=\"true\"\n          />\n          <p className=\"text-sm text-muted-foreground\">No tokens found</p>\n        </CardContent>\n      </Card>\n    )\n  }\n\n  // Token list\n  return (\n    <Card className={cn(\"w-full overflow-hidden\", className)}>\n      <CardContent className=\"p-0\">\n        {filteredTokens.map((token, index) => (\n          <TokenRow\n            key={token.tokenId}\n            token={token}\n            onSelect={onSelect}\n            onExternalLink={onExternalLink}\n            isLast={index === filteredTokens.length - 1}\n          />\n        ))}\n      </CardContent>\n    </Card>\n  )\n}\n",
      "type": "registry:component",
      "target": "~/components/blocks/token-list/token-list-ui.tsx"
    },
    {
      "path": "registry/new-york/blocks/token-list/use-token-list.ts",
      "content": "import { useCallback, useEffect, useMemo, useRef, useState } from \"react\"\n\n// ---------------------------------------------------------------------------\n// Types\n// ---------------------------------------------------------------------------\n\n/** Token standard */\nexport type TokenType = \"BSV20\" | \"BSV21\"\n\n/** Protocol filter for token queries */\nexport type TokenProtocol = \"bsv20\" | \"bsv21\" | \"all\"\n\n/** A single fungible token holding */\nexport interface TokenHolding {\n  /** Token contract/inscription ID */\n  tokenId: string\n  /** Token symbol or ticker */\n  symbol: string\n  /** Token type standard */\n  type: TokenType\n  /** Raw balance as a string (before decimal adjustment) */\n  balance: string\n  /** Decimal precision for the token */\n  decimals: number\n  /** ORDFS URL for the token icon (nullable). Pre-populated values skip ORDFS lookup. */\n  iconUrl: string | null\n}\n\n/** Shape returned from the 1sat API for a BSV21 token */\ninterface Bsv21TokenResponse {\n  id: string\n  sym: string\n  icon: string | null\n  dec: number\n  amt: string\n}\n\n/** Shape returned from the 1sat API for a BSV20 token */\ninterface Bsv20TokenResponse {\n  tick: string\n  dec: number\n  amt: string\n  icon?: string | null\n}\n\n/** Shape returned from the balance endpoint */\ninterface TokenBalanceResponse {\n  confirmed: string\n  pending: string\n}\n\nexport interface UseTokenListOptions {\n  /** BSV payment address to fetch token holdings for */\n  address: string | null\n  /** Token IDs to fetch. When provided, only these tokens are fetched. */\n  tokenIds?: string[]\n  /** Pre-populated token holdings (skips API fetch for tokens with matching IDs) */\n  tokens?: TokenHolding[]\n  /** Custom API base URL (default: https://api.1sat.app) */\n  apiUrl?: string\n  /** ORDFS base URL for icon resolution (default: https://ordfs.network) */\n  ordfsBase?: string\n  /** Filter by protocol type (default: \"all\") */\n  protocol?: TokenProtocol\n  /** Whether to auto-fetch on mount / address change (default: true) */\n  autoFetch?: boolean\n}\n\nexport interface UseTokenListReturn {\n  /** List of token holdings */\n  tokens: TokenHolding[]\n  /** Whether the initial fetch is in progress */\n  isLoading: boolean\n  /** Error from the last fetch attempt */\n  error: Error | null\n  /** Manually trigger a refetch */\n  refetch: () => void\n}\n\n// ---------------------------------------------------------------------------\n// Constants\n// ---------------------------------------------------------------------------\n\nconst DEFAULT_API_URL = \"https://api.1sat.app\"\nconst DEFAULT_ORDFS_BASE = \"https://ordfs.network\"\n\n// ---------------------------------------------------------------------------\n// Helpers\n// ---------------------------------------------------------------------------\n\nfunction ordfsIconUrl(tokenId: string, ordfsBase: string): string {\n  return `${ordfsBase}/${tokenId}`\n}\n\nasync function fetchJson<T>(url: string, signal?: AbortSignal): Promise<T> {\n  const res = await fetch(url, { signal })\n  if (!res.ok) {\n    throw new Error(`HTTP ${res.status}: ${url}`)\n  }\n  return res.json() as Promise<T>\n}\n\n// ---------------------------------------------------------------------------\n// Hook\n// ---------------------------------------------------------------------------\n\n/**\n * Fetches BSV20/BSV21 fungible token holdings for a given address.\n *\n * When `tokenIds` are provided, fetches details and balances for those\n * specific tokens. Otherwise the caller should provide a pre-populated list\n * or rely on wallet integration that surfaces token IDs.\n *\n * @example\n * ```ts\n * const { tokens, isLoading, error, refetch } = useTokenList({\n *   address: \"1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa\",\n *   tokenIds: [\"abc123_0\", \"def456_0\"],\n * })\n * ```\n */\nexport function useTokenList(\n  options: UseTokenListOptions\n): UseTokenListReturn {\n  const {\n    address,\n    tokenIds,\n    tokens: prePopulated,\n    apiUrl = DEFAULT_API_URL,\n    ordfsBase = DEFAULT_ORDFS_BASE,\n    protocol = \"all\",\n    autoFetch = true,\n  } = options\n\n  const [tokens, setTokens] = useState<TokenHolding[]>([])\n  const [isLoading, setIsLoading] = useState(false)\n  const [error, setError] = useState<Error | null>(null)\n  const abortRef = useRef<AbortController | null>(null)\n\n  // Stable stringified token ID list for dependency tracking\n  const tokenIdKey = useMemo(\n    () => (tokenIds ? tokenIds.slice().sort().join(\",\") : \"\"),\n    [tokenIds]\n  )\n\n  // Build a lookup map from pre-populated tokens for fast access\n  const prePopulatedMap = useMemo(() => {\n    if (!prePopulated) return null\n    const map = new Map<string, TokenHolding>()\n    for (const t of prePopulated) {\n      map.set(t.tokenId, t)\n    }\n    return map\n  }, [prePopulated])\n\n  const fetchTokens = useCallback(async () => {\n    if (!address || !tokenIds || tokenIds.length === 0) {\n      setTokens([])\n      return\n    }\n\n    abortRef.current?.abort()\n    const controller = new AbortController()\n    abortRef.current = controller\n\n    setIsLoading(true)\n    setError(null)\n\n    try {\n      const holdings: TokenHolding[] = await Promise.all(\n        tokenIds.map(async (id) => {\n          // If a pre-populated holding exists with this ID, use it directly\n          const existing = prePopulatedMap?.get(id)\n          if (existing) {\n            return existing\n          }\n\n          // Try BSV21 first, then BSV20\n          let symbol = id.slice(0, 8)\n          let tokenType: TokenType = \"BSV21\"\n          let decimals = 0\n          let hasIcon = false\n          let balanceEndpoint = `${apiUrl}/1sat/bsv21/${id}/p2pkh/${address}/balance`\n\n          try {\n            const detail = await fetchJson<Bsv21TokenResponse>(\n              `${apiUrl}/1sat/bsv21/${id}`,\n              controller.signal\n            )\n            symbol = detail.sym || id.slice(0, 8)\n            tokenType = \"BSV21\"\n            decimals = detail.dec ?? 0\n            hasIcon = !!detail.icon\n          } catch {\n            // BSV21 lookup failed, try BSV20\n            try {\n              const detail20 = await fetchJson<Bsv20TokenResponse>(\n                `${apiUrl}/1sat/bsv20/${id}`,\n                controller.signal\n              )\n              symbol = detail20.tick || id.slice(0, 8)\n              tokenType = \"BSV20\"\n              decimals = detail20.dec ?? 0\n              hasIcon = !!detail20.icon\n              balanceEndpoint = `${apiUrl}/1sat/bsv20/${id}/p2pkh/${address}/balance`\n            } catch {\n              // Neither endpoint found; keep defaults\n            }\n          }\n\n          // Fetch balance for this token + address\n          let balance = \"0\"\n          try {\n            const balanceData = await fetchJson<TokenBalanceResponse>(\n              balanceEndpoint,\n              controller.signal\n            )\n            // Combine confirmed + pending\n            const confirmed = BigInt(balanceData.confirmed || \"0\")\n            const pending = BigInt(balanceData.pending || \"0\")\n            balance = (confirmed + pending).toString()\n          } catch {\n            // Balance endpoint may 404 if no holdings; that is OK\n            balance = \"0\"\n          }\n\n          return {\n            tokenId: id,\n            symbol,\n            type: tokenType,\n            balance,\n            decimals,\n            iconUrl: hasIcon ? ordfsIconUrl(id, ordfsBase) : null,\n          }\n        })\n      )\n\n      // Apply protocol filter\n      const filtered =\n        protocol === \"all\"\n          ? holdings\n          : holdings.filter(\n              (h) => h.type.toLowerCase() === protocol\n            )\n\n      setTokens(filtered)\n    } catch (err) {\n      if (err instanceof DOMException && err.name === \"AbortError\") return\n      const fetchError =\n        err instanceof Error ? err : new Error(\"Failed to fetch tokens\")\n      setError(fetchError)\n      setTokens([])\n    } finally {\n      setIsLoading(false)\n    }\n  }, [address, tokenIdKey, apiUrl, ordfsBase, protocol, prePopulatedMap])\n\n  // Auto-fetch on mount / dependency change\n  useEffect(() => {\n    if (autoFetch && address && tokenIds && tokenIds.length > 0) {\n      void fetchTokens()\n    }\n\n    return () => {\n      abortRef.current?.abort()\n    }\n  }, [autoFetch, fetchTokens, address, tokenIdKey])\n\n  return {\n    tokens,\n    isLoading,\n    error,\n    refetch: fetchTokens,\n  }\n}\n",
      "type": "registry:component",
      "target": "~/components/blocks/token-list/use-token-list.ts"
    }
  ],
  "type": "registry:block"
}