Harden explorer MetaMask data and navigation coverage
This commit is contained in:
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user