Files
explorer-monorepo/frontend/src/components/wallet/AddToMetaMask.tsx
defiQUG 8cd8bfa195
All checks were successful
phoenix-deploy Deployed to explorer-live
Deploy Explorer Live / deploy (push) Successful in 2m18s
Unify explorer DBIS taxonomy and branding
2026-04-30 03:06:49 -07:00

708 lines
27 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use client'
import { useEffect, useMemo, useState } from 'react'
import { resolveExplorerApiBase } from '@/libs/frontend-api-client/api-base'
export type WalletChain = {
chainId: string
chainIdDecimal?: number
chainName: string
rpcUrls: string[]
blockExplorerUrls: string[]
iconUrls?: string[]
nativeCurrency: {
name: string
symbol: string
decimals: number
}
shortName?: string
infoURL?: string
explorerApiUrl?: string
}
export type TokenListToken = {
chainId: number
address: string
name: string
symbol: string
decimals: number
logoURI?: string
tags?: string[]
extensions?: Record<string, unknown>
}
export type NetworksCatalog = {
name?: string
version?: {
major?: number
minor?: number
patch?: number
}
defaultChainId?: number
chains?: WalletChain[]
}
export type TokenListCatalog = {
name?: string
version?: {
major?: number
minor?: number
patch?: number
}
keywords?: string[]
tokens?: TokenListToken[]
}
export type CapabilitiesCatalog = {
name?: string
version?: {
major?: number
minor?: number
patch?: number
}
timestamp?: string
chainId?: number
chainName?: string
rpcUrl?: string
explorerUrl?: string
explorerApiUrl?: string
generatedBy?: string
walletSupport?: {
walletAddEthereumChain?: boolean
walletWatchAsset?: boolean
notes?: string[]
}
http?: {
supportedMethods?: string[]
unsupportedMethods?: string[]
notes?: string[]
}
tracing?: {
supportedMethods?: string[]
unsupportedMethods?: string[]
notes?: string[]
}
}
export type FetchMetadata = {
source?: string | null
lastModified?: string | null
}
interface AddToMetaMaskProps {
initialNetworks?: NetworksCatalog | null
initialTokenList?: TokenListCatalog | null
initialCapabilities?: CapabilitiesCatalog | null
initialNetworksMeta?: FetchMetadata | null
initialTokenListMeta?: FetchMetadata | null
initialCapabilitiesMeta?: FetchMetadata | null
}
type EthereumProvider = {
request: (args: { method: string; params?: unknown }) => Promise<unknown>
}
const FALLBACK_CHAIN_138: WalletChain = {
chainId: '0x8a',
chainIdDecimal: 138,
chainName: 'DeFi Oracle Meta Mainnet',
nativeCurrency: { name: 'Ether', symbol: 'ETH', decimals: 18 },
rpcUrls: ['https://rpc-http-pub.d-bis.org', 'https://rpc.d-bis.org', 'https://rpc2.d-bis.org'],
blockExplorerUrls: ['https://explorer.d-bis.org', 'https://blockscout.defi-oracle.io'],
iconUrls: ['https://raw.githubusercontent.com/ethereum/ethereum.org/main/static/images/eth-diamond-black.png'],
shortName: 'dbis',
infoURL: 'https://explorer.d-bis.org',
explorerApiUrl: 'https://explorer.d-bis.org/api/v2',
}
const FALLBACK_ETHEREUM: WalletChain = {
chainId: '0x1',
chainIdDecimal: 1,
chainName: 'Ethereum Mainnet',
nativeCurrency: { name: 'Ether', symbol: 'ETH', decimals: 18 },
rpcUrls: ['https://eth.llamarpc.com', 'https://rpc.ankr.com/eth', 'https://ethereum.publicnode.com'],
blockExplorerUrls: ['https://etherscan.io'],
iconUrls: ['https://raw.githubusercontent.com/ethereum/ethereum.org/main/static/images/eth-diamond-black.png'],
shortName: 'eth',
infoURL: 'https://ethereum.org',
}
const FALLBACK_ALL_MAINNET: WalletChain = {
chainId: '0x9f2c4',
chainIdDecimal: 651940,
chainName: 'ALL Mainnet',
nativeCurrency: { name: 'Ether', symbol: 'ETH', decimals: 18 },
rpcUrls: ['https://mainnet-rpc.alltra.global'],
blockExplorerUrls: ['https://alltra.global'],
iconUrls: ['https://raw.githubusercontent.com/ethereum/ethereum.org/main/static/images/eth-diamond-black.png'],
shortName: 'all',
infoURL: 'https://alltra.global',
}
const FEATURED_TOKEN_SYMBOLS = ['cUSDT', 'cUSDC', 'USDT', 'USDC', 'cXAUC', 'cXAUT']
/** npm-published Snap using open Snap permissions only; stable MetaMask still requires MetaMasks install allowlist. */
const CHAIN138_OPEN_SNAP_ID = 'npm:chain138-open-snap' as const
const FALLBACK_CAPABILITIES_138: CapabilitiesCatalog = {
name: 'Chain 138 RPC Capabilities',
version: { major: 1, minor: 1, patch: 0 },
timestamp: '2026-03-28T00:00:00Z',
generatedBy: 'DBIS Explorer',
chainId: 138,
chainName: 'DeFi Oracle Meta Mainnet',
rpcUrl: 'https://rpc-http-pub.d-bis.org',
explorerUrl: 'https://explorer.d-bis.org',
explorerApiUrl: 'https://explorer.d-bis.org/api/v2',
walletSupport: {
walletAddEthereumChain: true,
walletWatchAsset: true,
notes: [
'MetaMask primarily relies on JSON-RPC correctness for balances, gas estimation, calls, and transaction submission.',
'Explorer-served network metadata and token list metadata complement wallet UX but do not replace RPC method support.',
],
},
http: {
supportedMethods: [
'web3_clientVersion',
'net_version',
'eth_chainId',
'eth_blockNumber',
'eth_syncing',
'eth_gasPrice',
'eth_maxPriorityFeePerGas',
'eth_feeHistory',
'eth_estimateGas',
'eth_getCode',
],
unsupportedMethods: [],
notes: [
'eth_feeHistory is available for wallet fee estimation.',
'eth_maxPriorityFeePerGas is exposed on the public RPC for wallet-grade fee suggestion compatibility.',
],
},
tracing: {
supportedMethods: ['trace_block', 'trace_replayBlockTransactions'],
unsupportedMethods: ['debug_traceBlockByNumber'],
notes: [
'TRACE support is enabled for explorer-grade indexing and internal transaction analysis.',
'Debug tracing is intentionally not enabled on the public RPC tier.',
],
},
}
function isTokenListToken(value: unknown): value is TokenListToken {
if (!value || typeof value !== 'object') return false
const candidate = value as Partial<TokenListToken>
return (
typeof candidate.chainId === 'number' &&
typeof candidate.address === 'string' &&
candidate.address.trim().length > 0 &&
typeof candidate.name === 'string' &&
typeof candidate.symbol === 'string' &&
typeof candidate.decimals === 'number'
)
}
function isCapabilitiesCatalog(value: unknown): value is CapabilitiesCatalog {
if (!value || typeof value !== 'object') return false
const candidate = value as Partial<CapabilitiesCatalog>
return (
typeof candidate.chainId === 'number' &&
typeof candidate.chainName === 'string' &&
candidate.chainName.trim().length > 0 &&
typeof candidate.rpcUrl === 'string' &&
candidate.rpcUrl.trim().length > 0
)
}
function getApiBase() {
return resolveExplorerApiBase({
serverFallback: 'https://blockscout.defi-oracle.io',
})
}
export function AddToMetaMask({
initialNetworks = null,
initialTokenList = null,
initialCapabilities = null,
initialNetworksMeta = null,
initialTokenListMeta = null,
initialCapabilitiesMeta = null,
}: AddToMetaMaskProps) {
const [status, setStatus] = useState<string | null>(null)
const [error, setError] = useState<string | null>(null)
const [networks, setNetworks] = useState<NetworksCatalog | null>(initialNetworks)
const [tokenList, setTokenList] = useState<TokenListCatalog | null>(initialTokenList)
const [capabilities, setCapabilities] = useState<CapabilitiesCatalog | null>(
initialCapabilities || FALLBACK_CAPABILITIES_138,
)
const [networksMeta, setNetworksMeta] = useState<FetchMetadata | null>(initialNetworksMeta)
const [tokenListMeta, setTokenListMeta] = useState<FetchMetadata | null>(initialTokenListMeta)
const [capabilitiesMeta, setCapabilitiesMeta] = useState<FetchMetadata | null>(
initialCapabilitiesMeta ||
(initialCapabilities
? {
source: 'explorer-api',
lastModified: initialCapabilities.timestamp || null,
}
: {
source: 'frontend-fallback',
lastModified: FALLBACK_CAPABILITIES_138.timestamp || null,
}),
)
const ethereum = typeof window !== 'undefined'
? (window as unknown as { ethereum?: EthereumProvider }).ethereum
: undefined
const apiBase = getApiBase().replace(/\/$/, '')
const tokenListUrl = `${apiBase}/api/config/token-list`
const networksUrl = `${apiBase}/api/config/networks`
const capabilitiesUrl = `${apiBase}/api/config/capabilities`
const staticCapabilitiesUrl =
typeof window !== 'undefined'
? `${window.location.origin.replace(/\/$/, '')}/config/CHAIN138_RPC_CAPABILITIES.json`
: `${apiBase}/config/CHAIN138_RPC_CAPABILITIES.json`
useEffect(() => {
let active = true
let timer: ReturnType<typeof setTimeout> | null = null
async function fetchJson(url: string) {
const response = await fetch(url, {
cache: 'no-store',
headers: {
'Cache-Control': 'no-cache',
},
})
const json = response.ok ? await response.json() : null
const meta: FetchMetadata = {
source: response.headers.get('X-Config-Source') || 'explorer-api',
lastModified: response.headers.get('Last-Modified'),
}
return { json, meta }
}
async function loadCatalogs() {
try {
const [networksResponse, tokenListResponse, capabilitiesResponse] = await Promise.all([
fetchJson(networksUrl),
fetchJson(tokenListUrl),
fetchJson(capabilitiesUrl),
])
let resolvedCapabilities = capabilitiesResponse
if (!isCapabilitiesCatalog(resolvedCapabilities.json)) {
const staticCapabilitiesResponse = await fetchJson(staticCapabilitiesUrl)
if (isCapabilitiesCatalog(staticCapabilitiesResponse.json)) {
resolvedCapabilities = {
json: staticCapabilitiesResponse.json,
meta: {
source: staticCapabilitiesResponse.meta.source || 'public-static-fallback',
lastModified: staticCapabilitiesResponse.meta.lastModified,
},
}
} else {
resolvedCapabilities = {
json: FALLBACK_CAPABILITIES_138,
meta: {
source: 'frontend-fallback',
lastModified: FALLBACK_CAPABILITIES_138.timestamp || null,
},
}
}
}
if (!active) return
setNetworks(networksResponse.json)
setTokenList(tokenListResponse.json)
setCapabilities(resolvedCapabilities.json)
setNetworksMeta(networksResponse.meta)
setTokenListMeta(tokenListResponse.meta)
setCapabilitiesMeta(resolvedCapabilities.meta)
} catch {
if (!active) return
setNetworks((current) => current)
setTokenList((current) => current)
setCapabilities((current) => current || FALLBACK_CAPABILITIES_138)
setNetworksMeta((current) => current)
setTokenListMeta((current) => current)
setCapabilitiesMeta((current) =>
current || {
source: 'frontend-fallback',
lastModified: FALLBACK_CAPABILITIES_138.timestamp || null,
},
)
} finally {
if (active) {
timer = setTimeout(() => {
void loadCatalogs()
}, 60_000)
}
}
}
void loadCatalogs()
return () => {
active = false
if (timer) clearTimeout(timer)
}
}, [capabilitiesUrl, networksUrl, staticCapabilitiesUrl, tokenListUrl])
const catalogTokens = useMemo(
() => (Array.isArray(tokenList?.tokens) ? tokenList.tokens.filter(isTokenListToken) : []),
[tokenList],
)
const chains = useMemo(() => {
const chainMap = new Map<number, WalletChain>()
for (const chain of networks?.chains || []) {
if (typeof chain.chainIdDecimal === 'number') {
chainMap.set(chain.chainIdDecimal, chain)
}
}
return {
chain138: chainMap.get(138) || FALLBACK_CHAIN_138,
ethereum: chainMap.get(1) || FALLBACK_ETHEREUM,
allMainnet: chainMap.get(651940) || FALLBACK_ALL_MAINNET,
total: (networks?.chains || []).length,
}
}, [networks])
const featuredTokens = useMemo(() => {
const tokenMap = new Map<string, TokenListToken>()
for (const token of catalogTokens) {
if (token.chainId !== 138) continue
if (!FEATURED_TOKEN_SYMBOLS.includes(token.symbol)) continue
tokenMap.set(token.symbol, token)
}
return FEATURED_TOKEN_SYMBOLS
.map((symbol) => tokenMap.get(symbol))
.filter((token): token is TokenListToken => !!token)
}, [catalogTokens])
const addChain = async (chain: WalletChain) => {
setError(null)
setStatus(null)
if (!ethereum) {
setError('MetaMask or another Web3 wallet is not installed.')
return
}
try {
await ethereum.request({
method: 'wallet_addEthereumChain',
params: [chain],
})
setStatus(`Added ${chain.chainName}. You can switch to it in your wallet.`)
} catch (e) {
const err = e as { code?: number; message?: string }
if (err.code === 4902) {
setStatus(`Added ${chain.chainName}. You can switch to it in your wallet.`)
} else {
setError(err.message || `Failed to add ${chain.chainName}.`)
}
}
}
const installOpenSnap = async () => {
setError(null)
setStatus(null)
if (!ethereum) {
setError('MetaMask or another Web3 wallet is not installed.')
return
}
try {
await ethereum.request({
method: 'wallet_requestSnaps',
params: { [CHAIN138_OPEN_SNAP_ID]: {} },
})
setStatus(
`Installed or connected to ${CHAIN138_OPEN_SNAP_ID}. In MetaMask, open Snaps → Chain 138 Open for the home page (token list URL, network info).`,
)
} catch (e) {
const err = e as { message?: string }
const msg = err.message || ''
const allowlistBlocked = /allowlist/i.test(msg)
if (allowlistBlocked && msg) {
setError(
`${msg} Production MetaMask only installs allowlisted Snaps from npm. Use MetaMask Flask for unrestricted installs during development, or request allowlisting via MetaMasks Snaps documentation.`,
)
} else {
setError(
msg ||
`Could not install Snap. Enable MetaMask Snaps and ensure ${CHAIN138_OPEN_SNAP_ID} is published on npm.`,
)
}
}
}
const watchToken = async (token: TokenListToken) => {
setError(null)
setStatus(null)
if (!isTokenListToken(token)) {
setError('Token metadata is incomplete right now. Refresh the page and try again.')
return
}
if (!ethereum) {
setError('MetaMask or another Web3 wallet is not installed.')
return
}
try {
const added = await ethereum.request({
method: 'wallet_watchAsset',
params: {
type: 'ERC20',
options: {
address: token.address,
symbol: token.symbol,
decimals: token.decimals,
image: token.logoURI,
},
},
})
setStatus(added ? `Added ${token.symbol} to your wallet.` : `${token.symbol} request was dismissed.`)
} catch (e) {
const err = e as { message?: string }
setError(err.message || `Failed to add ${token.symbol}.`)
}
}
const copyText = async (value: string, label: string) => {
setError(null)
setStatus(null)
try {
await navigator.clipboard.writeText(value)
setStatus(`Copied ${label}.`)
} catch {
setError(`Could not copy ${label}.`)
}
}
const tokenCount138 = catalogTokens.filter((token) => token.chainId === 138).length
const metadataKeywordString = (tokenList?.keywords || []).join(', ')
const supportedHTTPMethods = capabilities?.http?.supportedMethods || []
const unsupportedHTTPMethods = capabilities?.http?.unsupportedMethods || []
const supportedTraceMethods = capabilities?.tracing?.supportedMethods || []
const displayedCapabilitiesUrl =
capabilitiesMeta?.source === 'public-static-fallback' || capabilitiesMeta?.source === 'frontend-fallback'
? staticCapabilitiesUrl
: capabilitiesUrl
return (
<div className="space-y-4 rounded-xl border border-gray-200 bg-white p-4 dark:border-gray-700 dark:bg-gray-800 sm:p-5">
<h2 className="text-lg font-semibold">Add to MetaMask</h2>
<p className="text-sm text-gray-600 dark:text-gray-400">
The wallet tools now read the same explorer-served network catalog and token list that MetaMask can consume.
That keeps chain metadata, token metadata, and optional extensions aligned with the live explorer API instead of
relying on stale frontend-only defaults. MetaMask does not run built-in token detection on custom networks such
as Chain 138: add the token list URL below under Settings Security & privacy Token lists so tokens and
icons load automatically when you are on this chain.
</p>
<div className="grid gap-3 md:grid-cols-3">
<button
type="button"
onClick={() => addChain(chains.chain138)}
className="rounded bg-primary-600 px-4 py-2 text-sm font-medium text-white hover:bg-primary-700"
>
Add Chain 138
</button>
<button
type="button"
onClick={() => addChain(chains.ethereum)}
className="rounded bg-gray-600 px-4 py-2 text-sm font-medium text-white hover:bg-gray-700"
>
Add Ethereum Mainnet
</button>
<button
type="button"
onClick={() => addChain(chains.allMainnet)}
className="rounded bg-gray-600 px-4 py-2 text-sm font-medium text-white hover:bg-gray-700"
>
Add ALL Mainnet
</button>
</div>
<div className="rounded-lg border border-primary-200 bg-primary-50/40 p-4 dark:border-primary-900 dark:bg-primary-950/20">
<div className="text-sm font-semibold text-gray-900 dark:text-white">Chain 138 Open Snap</div>
<p className="mt-2 text-sm text-gray-600 dark:text-gray-400">
Optional MetaMask Snap that uses{' '}
<span className="font-medium text-gray-800 dark:text-gray-200">only open Snap permissions</span> (minimal
privileged APIs in the Snap itself).{' '}
<span className="font-medium text-gray-800 dark:text-gray-200">Stable MetaMask</span> still only installs npm
Snaps that appear on MetaMask&apos;s install allowlist; if install fails with &quot;not on the allowlist&quot;,
use <span className="font-medium text-gray-800 dark:text-gray-200">MetaMask Flask</span> for development or apply
for allowlisting. It adds in-wallet weekly reminders, Chain 138 transaction/signature hints, and the token list
URL on the Snap home page. The package on npm is{' '}
<code className="break-all rounded bg-gray-100 px-1 text-xs dark:bg-gray-900">{CHAIN138_OPEN_SNAP_ID}</code>
publish from the repo with <code className="break-all rounded bg-gray-100 px-1 text-xs dark:bg-gray-900">scripts/deployment/publish-chain138-open-snap.sh</code> after{' '}
<code className="rounded bg-gray-100 px-1 text-xs dark:bg-gray-900">npm login</code>.
</p>
<button
type="button"
onClick={() => void installOpenSnap()}
className="mt-3 rounded bg-primary-600 px-4 py-2 text-sm font-medium text-white hover:bg-primary-700"
>
Install Open Snap
</button>
</div>
<div className="grid gap-4 lg:grid-cols-2">
<div className="rounded-lg border border-gray-200 p-4 dark:border-gray-700">
<div className="text-sm font-semibold text-gray-900 dark:text-white">Explorer-served MetaMask metadata</div>
<div className="mt-3 space-y-2 text-sm text-gray-600 dark:text-gray-400">
<p>Networks catalog: {chains.total > 0 ? `${chains.total} chains` : 'using frontend fallback values'}</p>
<p>Chain 138 token entries: {tokenCount138}</p>
<p>Networks source: {networksMeta?.source || 'unknown'}</p>
<p>Token list source: {tokenListMeta?.source || 'unknown'}</p>
{metadataKeywordString ? <p>Keywords: {metadataKeywordString}</p> : null}
</div>
<div className="mt-4 space-y-3">
<div>
<p className="mb-1 text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">Networks config URL</p>
<code className="block break-all rounded bg-gray-100 p-2 text-xs dark:bg-gray-900">{networksUrl}</code>
<div className="mt-2 flex flex-wrap gap-2">
<button type="button" onClick={() => copyText(networksUrl, 'networks config URL')} className="rounded bg-gray-100 px-3 py-1.5 text-xs text-gray-700 hover:bg-gray-200 dark:bg-gray-900 dark:text-gray-200 dark:hover:bg-gray-700">
Copy URL
</button>
<a href={networksUrl} target="_blank" rel="noopener noreferrer" className="rounded bg-gray-100 px-3 py-1.5 text-xs text-gray-700 hover:bg-gray-200 dark:bg-gray-900 dark:text-gray-200 dark:hover:bg-gray-700">
Open JSON
</a>
</div>
</div>
<div>
<p className="mb-1 text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">Capabilities URL</p>
<code className="block break-all rounded bg-gray-100 p-2 text-xs dark:bg-gray-900">{displayedCapabilitiesUrl}</code>
<div className="mt-2 flex flex-wrap gap-2">
<button type="button" onClick={() => copyText(displayedCapabilitiesUrl, 'capabilities URL')} className="rounded bg-gray-100 px-3 py-1.5 text-xs text-gray-700 hover:bg-gray-200 dark:bg-gray-900 dark:text-gray-200 dark:hover:bg-gray-700">
Copy URL
</button>
<a href={displayedCapabilitiesUrl} target="_blank" rel="noopener noreferrer" className="rounded bg-gray-100 px-3 py-1.5 text-xs text-gray-700 hover:bg-gray-200 dark:bg-gray-900 dark:text-gray-200 dark:hover:bg-gray-700">
Open JSON
</a>
</div>
</div>
<div>
<p className="mb-1 text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">Token list URL</p>
<code className="block break-all rounded bg-gray-100 p-2 text-xs dark:bg-gray-900">{tokenListUrl}</code>
<div className="mt-2 flex flex-wrap gap-2">
<button type="button" onClick={() => copyText(tokenListUrl, 'token list URL')} className="rounded bg-gray-100 px-3 py-1.5 text-xs text-gray-700 hover:bg-gray-200 dark:bg-gray-900 dark:text-gray-200 dark:hover:bg-gray-700">
Copy URL
</button>
<a href={tokenListUrl} target="_blank" rel="noopener noreferrer" className="rounded bg-gray-100 px-3 py-1.5 text-xs text-gray-700 hover:bg-gray-200 dark:bg-gray-900 dark:text-gray-200 dark:hover:bg-gray-700">
Open JSON
</a>
</div>
</div>
</div>
</div>
<div className="rounded-lg border border-gray-200 p-4 dark:border-gray-700">
<div className="text-sm font-semibold text-gray-900 dark:text-white">Chain 138 RPC capabilities</div>
<p className="mt-2 text-sm text-gray-600 dark:text-gray-400">
This capability matrix documents what public RPC methods wallets can rely on today, what tracing the explorer
can use, and where MetaMask will still fall back because the public node does not expose every optional fee method.
</p>
<div className="mt-4 space-y-3 text-sm text-gray-600 dark:text-gray-400">
<p>
RPC endpoint:{' '}
<span className="font-medium text-gray-900 dark:text-white">
{capabilities?.rpcUrl || 'using published explorer fallback'}
</span>
</p>
<p>
Capabilities source:{' '}
<span className="font-medium text-gray-900 dark:text-white">
{capabilitiesMeta?.source || 'unknown'}
</span>
</p>
<p>
HTTP methods: {supportedHTTPMethods.length > 0 ? supportedHTTPMethods.join(', ') : 'metadata unavailable'}
</p>
<p>
Missing wallet-facing methods:{' '}
{unsupportedHTTPMethods.length > 0 ? unsupportedHTTPMethods.join(', ') : 'none listed'}
</p>
<p>
Trace methods: {supportedTraceMethods.length > 0 ? supportedTraceMethods.join(', ') : 'metadata unavailable'}
</p>
{capabilities?.walletSupport?.notes?.map((note) => (
<p key={note} className="text-xs">
{note}
</p>
))}
{capabilities?.http?.notes?.map((note) => (
<p key={note} className="text-xs">
{note}
</p>
))}
{capabilitiesMeta?.lastModified ? (
<p className="text-xs">
Last modified: {new Date(capabilitiesMeta.lastModified).toLocaleString()}
</p>
) : null}
</div>
</div>
<div className="rounded-lg border border-gray-200 p-4 dark:border-gray-700">
<div className="text-sm font-semibold text-gray-900 dark:text-white">Featured Chain 138 tokens</div>
<p className="mt-2 text-sm text-gray-600 dark:text-gray-400">
These tokens come from the explorer token list and use `wallet_watchAsset` so the wallet gets the same symbol,
decimals, image, and optional token metadata that the explorer publishes.
</p>
<div className="mt-4 space-y-3">
{featuredTokens.length === 0 ? (
<p className="text-sm text-gray-600 dark:text-gray-400">Featured token metadata is not available right now.</p>
) : featuredTokens.map((token) => (
<div key={token.address} className="rounded-lg border border-gray-200 p-3 dark:border-gray-700">
<div className="flex flex-col gap-2 md:flex-row md:items-center md:justify-between">
<div>
<div className="text-sm font-semibold text-gray-900 dark:text-white">
{token.symbol} <span className="font-normal text-gray-500 dark:text-gray-400">({token.name})</span>
</div>
<div className="mt-1 text-xs text-gray-500 dark:text-gray-400">
{token.address}
</div>
<div className="mt-1 text-xs text-gray-500 dark:text-gray-400">
Decimals: {token.decimals}
{token.tags?.length ? ` • Tags: ${token.tags.join(', ')}` : ''}
</div>
{typeof token.extensions?.unitDescription === 'string' ? (
<div className="mt-1 text-xs text-gray-500 dark:text-gray-400">{token.extensions.unitDescription}</div>
) : null}
</div>
<button
type="button"
onClick={() => watchToken(token)}
className="rounded bg-primary-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-primary-700"
>
Add {token.symbol}
</button>
</div>
</div>
))}
</div>
</div>
</div>
{status ? <p className="text-sm text-green-600 dark:text-green-400">{status}</p> : null}
{error ? <p className="text-sm text-red-600 dark:text-red-400">{error}</p> : null}
</div>
)
}