Harden explorer MetaMask data and navigation coverage

This commit is contained in:
defiQUG
2026-03-28 13:40:32 -07:00
parent 6096804ee6
commit 7cf9f450e4
8 changed files with 600 additions and 137 deletions

View File

@@ -1,44 +1,191 @@
'use client'
import { useState } from 'react'
import { useEffect, useMemo, useState } from 'react'
const CHAIN_138 = {
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
}
type TokenListToken = {
chainId: number
address: string
name: string
symbol: string
decimals: number
logoURI?: string
tags?: string[]
extensions?: Record<string, unknown>
}
type NetworksCatalog = {
name?: string
version?: {
major?: number
minor?: number
patch?: number
}
defaultChainId?: number
chains?: WalletChain[]
}
type TokenListCatalog = {
name?: string
version?: {
major?: number
minor?: number
patch?: number
}
keywords?: string[]
tokens?: TokenListToken[]
}
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'],
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 CHAIN_MAINNET = {
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 CHAIN_ALL_MAINNET = {
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']
function getApiBase() {
if (typeof window !== 'undefined') {
return process.env.NEXT_PUBLIC_API_URL || window.location.origin
}
return process.env.NEXT_PUBLIC_API_URL || 'https://explorer.d-bis.org'
}
export function AddToMetaMask() {
const [status, setStatus] = useState<string | null>(null)
const [error, setError] = useState<string | null>(null)
const [networks, setNetworks] = useState<NetworksCatalog | null>(null)
const [tokenList, setTokenList] = useState<TokenListCatalog | null>(null)
const ethereum = typeof window !== 'undefined' ? (window as unknown as { ethereum?: { request: (args: { method: string; params: unknown[] }) => Promise<unknown> } }).ethereum : undefined
const ethereum = typeof window !== 'undefined'
? (window as unknown as { ethereum?: EthereumProvider }).ethereum
: undefined
const addChain = async (chain: typeof CHAIN_138) => {
const apiBase = getApiBase().replace(/\/$/, '')
const tokenListUrl = `${apiBase}/api/config/token-list`
const networksUrl = `${apiBase}/api/config/networks`
useEffect(() => {
let active = true
async function loadCatalogs() {
try {
const [networksResponse, tokenListResponse] = await Promise.all([
fetch(networksUrl),
fetch(tokenListUrl),
])
const [networksJson, tokenListJson] = await Promise.all([
networksResponse.ok ? networksResponse.json() : null,
tokenListResponse.ok ? tokenListResponse.json() : null,
])
if (!active) return
setNetworks(networksJson)
setTokenList(tokenListJson)
} catch {
if (!active) return
setNetworks(null)
setTokenList(null)
}
}
loadCatalogs()
return () => {
active = false
}
}, [networksUrl, tokenListUrl])
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 tokenList?.tokens || []) {
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)
}, [tokenList])
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',
@@ -50,53 +197,168 @@ export function AddToMetaMask() {
if (err.code === 4902) {
setStatus(`Added ${chain.chainName}. You can switch to it in your wallet.`)
} else {
setError(err.message || 'Failed to add network')
setError(err.message || `Failed to add ${chain.chainName}.`)
}
}
}
// Production (explorer.d-bis.org): same origin; dev: NEXT_PUBLIC_API_URL or localhost
const apiBase =
typeof window !== 'undefined'
? process.env.NEXT_PUBLIC_API_URL || window.location.origin
: process.env.NEXT_PUBLIC_API_URL || 'https://explorer.d-bis.org'
const tokenListUrl = apiBase.replace(/\/$/, '') + '/api/config/token-list'
const watchToken = async (token: TokenListToken) => {
setError(null)
setStatus(null)
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 = (tokenList?.tokens || []).filter((token) => token.chainId === 138).length
const metadataKeywordString = (tokenList?.keywords || []).join(', ')
return (
<div className="rounded-lg border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 p-4 space-y-4">
<div className="space-y-4 rounded-lg border border-gray-200 bg-white p-4 dark:border-gray-700 dark:bg-gray-800">
<h2 className="text-lg font-semibold">Add to MetaMask</h2>
<p className="text-sm text-gray-600 dark:text-gray-400">
Add Chain 138 (DeFi Oracle Meta Mainnet), Ethereum Mainnet, or ALL Mainnet to your wallet. Then add the token list URL in MetaMask Settings so tokens appear automatically.
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.
</p>
<div className="flex flex-wrap gap-2">
<div className="grid gap-3 md:grid-cols-3">
<button
type="button"
onClick={() => addChain(CHAIN_138)}
className="px-4 py-2 rounded bg-primary-600 text-white hover:bg-primary-700 text-sm font-medium"
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(CHAIN_MAINNET)}
className="px-4 py-2 rounded bg-gray-600 text-white hover:bg-gray-700 text-sm font-medium"
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(CHAIN_ALL_MAINNET)}
className="px-4 py-2 rounded bg-gray-600 text-white hover:bg-gray-700 text-sm font-medium"
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="text-sm">
<p className="text-gray-600 dark:text-gray-400 mb-1">Token list URL (add in MetaMask Settings Token lists):</p>
<code className="block p-2 rounded bg-gray-100 dark:bg-gray-900 break-all text-xs">{tokenListUrl}</code>
<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>
{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">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">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>}
{error && <p className="text-sm text-red-600 dark:text-red-400">{error}</p>}
{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>
)
}