Harden explorer MetaMask data and navigation coverage

This commit is contained in:
defiQUG
2026-03-28 13:40:32 -07:00
parent 1e3a3f00ef
commit a2555b4149
8 changed files with 600 additions and 137 deletions

View File

@@ -1,10 +1,14 @@
{
"name": "MetaMask Multi-Chain Networks (13 chains)",
"version": {"major": 1, "minor": 2, "patch": 0},
"defaultChainId": 138,
"explorerUrl": "https://explorer.d-bis.org",
"tokenListUrl": "https://explorer.d-bis.org/api/config/token-list",
"generatedBy": "SolaceScanScout",
"chains": [
{"chainId":"0x8a","chainIdDecimal":138,"chainName":"DeFi Oracle Meta Mainnet","rpcUrls":["https://rpc-http-pub.d-bis.org","https://rpc.d-bis.org","https://rpc2.d-bis.org","https://rpc.defi-oracle.io"],"nativeCurrency":{"name":"Ether","symbol":"ETH","decimals":18},"blockExplorerUrls":["https://explorer.d-bis.org"],"iconUrls":["https://raw.githubusercontent.com/ethereum/ethereum.org/main/static/images/eth-diamond-black.png"]},
{"chainId":"0x1","chainIdDecimal":1,"chainName":"Ethereum Mainnet","rpcUrls":["https://eth.llamarpc.com","https://rpc.ankr.com/eth","https://ethereum.publicnode.com","https://1rpc.io/eth"],"nativeCurrency":{"name":"Ether","symbol":"ETH","decimals":18},"blockExplorerUrls":["https://etherscan.io"],"iconUrls":["https://raw.githubusercontent.com/ethereum/ethereum.org/main/static/images/eth-diamond-black.png"]},
{"chainId":"0x9f2c4","chainIdDecimal":651940,"chainName":"ALL Mainnet","rpcUrls":["https://mainnet-rpc.alltra.global"],"nativeCurrency":{"name":"Ether","symbol":"ETH","decimals":18},"blockExplorerUrls":["https://alltra.global"],"iconUrls":["https://raw.githubusercontent.com/ethereum/ethereum.org/main/static/images/eth-diamond-black.png"]},
{"chainId":"0x8a","chainIdDecimal":138,"chainName":"DeFi Oracle Meta Mainnet","shortName":"dbis","rpcUrls":["https://rpc-http-pub.d-bis.org","https://rpc.d-bis.org","https://rpc2.d-bis.org","https://rpc.defi-oracle.io"],"nativeCurrency":{"name":"Ether","symbol":"ETH","decimals":18},"blockExplorerUrls":["https://explorer.d-bis.org"],"iconUrls":["https://raw.githubusercontent.com/ethereum/ethereum.org/main/static/images/eth-diamond-black.png"],"infoURL":"https://explorer.d-bis.org","explorerApiUrl":"https://explorer.d-bis.org/api/v2","testnet":false},
{"chainId":"0x1","chainIdDecimal":1,"chainName":"Ethereum Mainnet","shortName":"eth","rpcUrls":["https://eth.llamarpc.com","https://rpc.ankr.com/eth","https://ethereum.publicnode.com","https://1rpc.io/eth"],"nativeCurrency":{"name":"Ether","symbol":"ETH","decimals":18},"blockExplorerUrls":["https://etherscan.io"],"iconUrls":["https://raw.githubusercontent.com/ethereum/ethereum.org/main/static/images/eth-diamond-black.png"],"infoURL":"https://ethereum.org","testnet":false},
{"chainId":"0x9f2c4","chainIdDecimal":651940,"chainName":"ALL Mainnet","shortName":"all","rpcUrls":["https://mainnet-rpc.alltra.global"],"nativeCurrency":{"name":"Ether","symbol":"ETH","decimals":18},"blockExplorerUrls":["https://alltra.global"],"iconUrls":["https://raw.githubusercontent.com/ethereum/ethereum.org/main/static/images/eth-diamond-black.png"],"infoURL":"https://alltra.global","testnet":false},
{"chainId":"0x19","chainIdDecimal":25,"chainName":"Cronos Mainnet","rpcUrls":["https://evm.cronos.org","https://cronos-rpc.publicnode.com"],"nativeCurrency":{"name":"CRO","symbol":"CRO","decimals":18},"blockExplorerUrls":["https://cronos.org/explorer"],"iconUrls":["https://ipfs.io/ipfs/Qma3FKtLce9MjgJgWbtyCxBiPjJ6xi8jGWUSKNS5Jc2ong"]},
{"chainId":"0x38","chainIdDecimal":56,"chainName":"BNB Smart Chain","rpcUrls":["https://bsc-dataseed.binance.org","https://bsc-dataseed1.defibit.io","https://bsc-dataseed1.ninicoin.io"],"nativeCurrency":{"name":"BNB","symbol":"BNB","decimals":18},"blockExplorerUrls":["https://bscscan.com"],"iconUrls":["https://raw.githubusercontent.com/ethereum/ethereum.org/main/static/images/eth-diamond-black.png"]},
{"chainId":"0x64","chainIdDecimal":100,"chainName":"Gnosis Chain","rpcUrls":["https://rpc.gnosischain.com","https://gnosis-rpc.publicnode.com","https://1rpc.io/gnosis"],"nativeCurrency":{"name":"xDAI","symbol":"xDAI","decimals":18},"blockExplorerUrls":["https://gnosisscan.io"],"iconUrls":["https://raw.githubusercontent.com/ethereum/ethereum.org/main/static/images/eth-diamond-black.png"]},

View File

@@ -7,6 +7,44 @@
},
"timestamp": "2026-03-26T09:17:26.866Z",
"logoURI": "https://ipfs.io/ipfs/Qma3FKtLce9MjgJgWbtyCxBiPjJ6xi8jGWUSKNS5Jc2ong",
"keywords": [
"chain138",
"defi-oracle-meta",
"multichain",
"metamask",
"wallet"
],
"tags": {
"stablecoin": {
"name": "Stablecoin",
"description": "Fiat-pegged and fiat-mirrored assets published for explorer and wallet discovery."
},
"defi": {
"name": "DeFi",
"description": "Assets surfaced across the explorer and DEX route matrix."
},
"compliant": {
"name": "Compliant",
"description": "Compliance-oriented assets deployed on Chain 138."
},
"oracle": {
"name": "Oracle",
"description": "Oracle or oracle-adjacent assets and price feed entries."
},
"wrapped": {
"name": "Wrapped",
"description": "Wrapped representations of native or bridged assets."
},
"ccip": {
"name": "CCIP",
"description": "Assets related to CCIP and bridge infrastructure."
}
},
"extensions": {
"defaultChainId": 138,
"explorerUrl": "https://explorer.d-bis.org",
"networksConfigUrl": "https://explorer.d-bis.org/api/config/networks"
},
"tokens": [
{
"chainId": 138,

View File

@@ -0,0 +1,163 @@
package rest
import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
)
type testNetworksCatalog struct {
Name string `json:"name"`
DefaultChainID int `json:"defaultChainId"`
ExplorerURL string `json:"explorerUrl"`
TokenListURL string `json:"tokenListUrl"`
GeneratedBy string `json:"generatedBy"`
Chains []struct {
ChainID string `json:"chainId"`
ChainIDDecimal int `json:"chainIdDecimal"`
ChainName string `json:"chainName"`
ShortName string `json:"shortName"`
RPCURLs []string `json:"rpcUrls"`
BlockExplorerURL []string `json:"blockExplorerUrls"`
InfoURL string `json:"infoURL"`
ExplorerAPIURL string `json:"explorerApiUrl"`
Testnet bool `json:"testnet"`
} `json:"chains"`
}
type testTokenList struct {
Name string `json:"name"`
Keywords []string
Extensions struct {
DefaultChainID int `json:"defaultChainId"`
ExplorerURL string `json:"explorerUrl"`
NetworksConfigURL string `json:"networksConfigUrl"`
} `json:"extensions"`
Tokens []struct {
ChainID int `json:"chainId"`
Address string `json:"address"`
Symbol string `json:"symbol"`
Decimals int `json:"decimals"`
Extensions struct {
UnitOfAccount string `json:"unitOfAccount"`
UnitDescription string `json:"unitDescription"`
} `json:"extensions"`
} `json:"tokens"`
}
func setupConfigHandler() http.Handler {
server := NewServer(nil, 138)
mux := http.NewServeMux()
server.SetupRoutes(mux)
return server.addMiddleware(mux)
}
func TestConfigNetworksEndpointProvidesWalletMetadata(t *testing.T) {
handler := setupConfigHandler()
req := httptest.NewRequest(http.MethodGet, "/api/config/networks", nil)
w := httptest.NewRecorder()
handler.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", w.Code)
}
if got := w.Header().Get("Access-Control-Allow-Origin"); got == "" {
t.Fatal("expected CORS header on config endpoint")
}
var payload testNetworksCatalog
if err := json.Unmarshal(w.Body.Bytes(), &payload); err != nil {
t.Fatalf("failed to parse networks payload: %v", err)
}
if payload.DefaultChainID != 138 {
t.Fatalf("expected defaultChainId 138, got %d", payload.DefaultChainID)
}
if payload.ExplorerURL == "" || payload.TokenListURL == "" || payload.GeneratedBy == "" {
t.Fatal("expected root metadata fields to be populated")
}
if len(payload.Chains) < 3 {
t.Fatalf("expected multiple chain entries, got %d", len(payload.Chains))
}
var foundChain138 bool
for _, chain := range payload.Chains {
if chain.ChainIDDecimal != 138 {
continue
}
foundChain138 = true
if chain.ShortName == "" || chain.InfoURL == "" || chain.ExplorerAPIURL == "" {
t.Fatal("expected Chain 138 optional metadata to be populated")
}
if len(chain.RPCURLs) == 0 || len(chain.BlockExplorerURL) == 0 {
t.Fatal("expected Chain 138 RPC and explorer URLs")
}
if chain.Testnet {
t.Fatal("expected Chain 138 to be marked as mainnet")
}
}
if !foundChain138 {
t.Fatal("expected Chain 138 entry in networks catalog")
}
}
func TestConfigTokenListEndpointProvidesOptionalMetadata(t *testing.T) {
handler := setupConfigHandler()
req := httptest.NewRequest(http.MethodGet, "/api/config/token-list", nil)
w := httptest.NewRecorder()
handler.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", w.Code)
}
var payload testTokenList
if err := json.Unmarshal(w.Body.Bytes(), &payload); err != nil {
t.Fatalf("failed to parse token list payload: %v", err)
}
if len(payload.Keywords) == 0 {
t.Fatal("expected token list keywords")
}
if payload.Extensions.DefaultChainID != 138 || payload.Extensions.ExplorerURL == "" || payload.Extensions.NetworksConfigURL == "" {
t.Fatal("expected root-level token list extensions")
}
var foundCXAUC bool
var foundCUSDT bool
for _, token := range payload.Tokens {
switch token.Symbol {
case "cXAUC":
foundCXAUC = true
if token.Extensions.UnitOfAccount == "" || token.Extensions.UnitDescription == "" {
t.Fatal("expected cXAUC optional unit metadata")
}
case "cUSDT":
foundCUSDT = true
if token.Decimals != 6 {
t.Fatalf("expected cUSDT decimals 6, got %d", token.Decimals)
}
}
}
if !foundCXAUC || !foundCUSDT {
t.Fatal("expected cXAUC and cUSDT in token list")
}
}
func TestConfigEndpointsSupportOptionsPreflight(t *testing.T) {
handler := setupConfigHandler()
req := httptest.NewRequest(http.MethodOptions, "/api/config/token-list", nil)
w := httptest.NewRecorder()
handler.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200 preflight response, got %d", w.Code)
}
if got := w.Header().Get("Access-Control-Allow-Methods"); got == "" {
t.Fatal("expected Access-Control-Allow-Methods header")
}
}

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>
)
}

View File

@@ -115,6 +115,15 @@ export default function AddressDetailPage() {
{addressInfo.label || 'Address'}
</h1>
<div className="mb-6 flex flex-wrap gap-3 text-sm">
<Link href="/addresses" className="text-primary-600 hover:underline">
Back to addresses
</Link>
<Link href={`/search?q=${encodeURIComponent(addressInfo.address)}`} className="text-primary-600 hover:underline">
Search this address
</Link>
</div>
<Card title="Address Information" className="mb-6">
<div className="space-y-4">
<div>

View File

@@ -60,6 +60,20 @@ export default function BlockDetailPage() {
<div className="container mx-auto px-4 py-8">
<h1 className="text-3xl font-bold mb-6">Block #{block.number}</h1>
<div className="mb-6 flex flex-wrap gap-3 text-sm">
<Link href="/blocks" className="text-primary-600 hover:underline">
Back to blocks
</Link>
{block.number > 0 ? (
<Link href={`/blocks/${block.number - 1}`} className="text-primary-600 hover:underline">
Previous block
</Link>
) : null}
<Link href={`/blocks/${block.number + 1}`} className="text-primary-600 hover:underline">
Next block
</Link>
</div>
<Card title="Block Information">
<div className="space-y-4">
<div>

View File

@@ -61,6 +61,15 @@ export default function TransactionDetailPage() {
<div className="container mx-auto px-4 py-8">
<h1 className="text-3xl font-bold mb-6">Transaction</h1>
<div className="mb-6 flex flex-wrap gap-3 text-sm">
<Link href="/transactions" className="text-primary-600 hover:underline">
Back to transactions
</Link>
<Link href={`/search?q=${encodeURIComponent(transaction.hash)}`} className="text-primary-600 hover:underline">
Search this hash
</Link>
</div>
<Card title="Transaction Information">
<div className="space-y-4">
<div>

View File

@@ -1,121 +1,85 @@
/**
* Explorer Frontend E2E Tests
* Tests all links and path-based routing on explorer.d-bis.org
* Tests live SPA links, route coverage, and detail-page navigation on explorer.d-bis.org.
* Run: npx playwright test explorer-monorepo/scripts/e2e-explorer-frontend.spec.ts --project=chromium
*/
import { test, expect } from '@playwright/test';
import { expect, test } from '@playwright/test'
const EXPLORER_URL = process.env.EXPLORER_URL || 'https://explorer.d-bis.org';
const ADDRESS_TEST = '0x99b3511a2d315a497c8112c1fdd8d508d4b1e506';
const EXPLORER_URL = process.env.EXPLORER_URL || 'https://explorer.d-bis.org'
const ADDRESS_TEST = '0x99b3511a2d315a497c8112c1fdd8d508d4b1e506'
test.describe('Explorer Frontend - Path-Based URLs', () => {
test('address path /address/0x... loads address detail', async ({ page }) => {
await page.goto(`${EXPLORER_URL}/address/${ADDRESS_TEST}`, { waitUntil: 'domcontentloaded', timeout: 20000 });
await page.waitForLoadState('domcontentloaded');
const hasBreadcrumb = await page.locator('#addressDetailBreadcrumb').count() > 0;
const bodyText = await page.locator('body').textContent().catch(() => '') || '';
expect(hasBreadcrumb || bodyText.length > 100).toBe(true);
expect(bodyText).toMatch(/Address|Balance|Transaction|0x99|Explorer|detail/i);
});
async function expectBodyToContain(page: Parameters<typeof test>[0], pattern: RegExp) {
const bodyText = await page.locator('body').textContent().catch(() => '') || ''
expect(bodyText).toMatch(pattern)
}
test('root path loads homepage', async ({ page }) => {
await page.goto(EXPLORER_URL, { waitUntil: 'load', timeout: 25000 });
// Logo and nav are always in the HTML; home view or stats appear once SPA runs
const body = await page.locator('body').textContent();
expect(body).toMatch(/SolaceScanScout|Explorer|Chain|Block|Transaction/i);
});
test.describe('Explorer Frontend - Path Coverage', () => {
for (const route of [
{ path: '/', matcher: /SolaceScanScout|Latest Blocks|Explorer/i },
{ path: '/blocks', matcher: /Blocks|Block/i },
{ path: '/transactions', matcher: /Transactions|Transaction/i },
{ path: '/addresses', matcher: /Addresses|Address/i },
{ path: '/watchlist', matcher: /Watchlist|Explorer/i },
{ path: '/pools', matcher: /Pools|Liquidity/i },
{ path: '/liquidity', matcher: /Liquidity|Route|Pool/i },
{ path: '/bridge', matcher: /Bridge|Explorer/i },
{ path: '/weth', matcher: /WETH|Explorer/i },
]) {
test(`${route.path} loads`, async ({ page }) => {
await page.goto(`${EXPLORER_URL}${route.path}`, { waitUntil: 'networkidle', timeout: 20000 })
await expect(page).toHaveURL(new RegExp(route.path === '/' ? '/?$' : route.path.replace('/', '\\/')), { timeout: 8000 })
await expectBodyToContain(page, route.matcher)
})
}
test('blocks path /blocks loads blocks list', async ({ page }) => {
await page.goto(`${EXPLORER_URL}/blocks`, { waitUntil: 'domcontentloaded', timeout: 15000 });
await expect(page.locator('text=Block').first()).toBeVisible({ timeout: 8000 });
});
test('/address/:address loads address detail', async ({ page }) => {
await page.goto(`${EXPLORER_URL}/address/${ADDRESS_TEST}`, { waitUntil: 'networkidle', timeout: 20000 })
await page.waitForSelector('#addressDetailBreadcrumb', { state: 'attached', timeout: 15000 })
await expectBodyToContain(page, /Address|Balance|Transaction|Explorer/i)
})
})
test('transactions path /transactions loads transactions list', async ({ page }) => {
await page.goto(`${EXPLORER_URL}/transactions`, { waitUntil: 'domcontentloaded', timeout: 15000 });
await expect(page.locator('text=Transaction').first()).toBeVisible({ timeout: 8000 });
});
test.describe('Explorer Frontend - Nav and Detail Links', () => {
test('MetaMask Snap link is present', async ({ page }) => {
await page.goto(EXPLORER_URL, { waitUntil: 'domcontentloaded', timeout: 20000 })
const snapLink = page.locator('a[href="/snap/"]').first()
await expect(snapLink).toBeVisible({ timeout: 8000 })
await expect(snapLink).toHaveAttribute('href', '/snap/')
})
test('bridge path /bridge loads and shows bridge or explorer', async ({ page }) => {
await page.goto(`${EXPLORER_URL}/bridge`, { waitUntil: 'networkidle', timeout: 20000 });
const url = page.url();
const body = await page.locator('body').textContent();
expect(url).toMatch(/\/bridge/);
expect(body).toMatch(/Bridge|SolaceScanScout|Explorer/i);
});
test('Address breadcrumb home link returns to root', async ({ page }) => {
await page.goto(`${EXPLORER_URL}/address/${ADDRESS_TEST}`, { waitUntil: 'networkidle', timeout: 20000 })
const homeLink = page.locator('#addressDetailBreadcrumb a[href="/"]').first()
await expect(homeLink).toBeVisible({ timeout: 8000 })
await homeLink.click()
await expect(page).toHaveURL(new RegExp(`${EXPLORER_URL.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}/?$`), { timeout: 8000 })
})
test('weth path /weth loads and shows WETH or explorer', async ({ page }) => {
await page.goto(`${EXPLORER_URL}/weth`, { waitUntil: 'networkidle', timeout: 20000 });
const url = page.url();
const body = await page.locator('body').textContent();
expect(url).toMatch(/\/weth/);
expect(body).toMatch(/WETH|SolaceScanScout|Explorer/i);
});
test('Blocks list opens block detail view', async ({ page }) => {
await page.goto(`${EXPLORER_URL}/blocks`, { waitUntil: 'networkidle', timeout: 20000 })
const blockLink = page.locator('[onclick*="showBlockDetail"]').first()
await expect(blockLink).toBeVisible({ timeout: 8000 })
await blockLink.click()
await expect(page.locator('#blockDetailView.active')).toBeVisible({ timeout: 8000 })
await expect(page.locator('#blockDetailBreadcrumb')).toBeVisible({ timeout: 8000 })
})
test('watchlist path /watchlist loads and shows watchlist or explorer', async ({ page }) => {
await page.goto(`${EXPLORER_URL}/watchlist`, { waitUntil: 'networkidle', timeout: 20000 });
const url = page.url();
const body = await page.locator('body').textContent();
expect(url).toMatch(/\/watchlist/);
expect(body).toMatch(/Watchlist|SolaceScanScout|Explorer/i);
});
});
test('Transactions list opens transaction detail view', async ({ page }) => {
await page.goto(`${EXPLORER_URL}/transactions`, { waitUntil: 'networkidle', timeout: 20000 })
const transactionLink = page.locator('[onclick*="showTransactionDetail"]').first()
await expect(transactionLink).toBeVisible({ timeout: 8000 })
await transactionLink.click()
await expect(page.locator('#transactionDetailView.active')).toBeVisible({ timeout: 8000 })
await expect(page.locator('#transactionDetailBreadcrumb')).toBeVisible({ timeout: 8000 })
})
test.describe('Explorer Frontend - Nav Links (path-based routing)', () => {
test('Root shows home content', async ({ page }) => {
await page.goto(EXPLORER_URL, { waitUntil: 'load', timeout: 20000 });
await expect(page.locator('#homeView').or(page.getByText('Latest Blocks')).first()).toBeVisible({ timeout: 10000 });
});
test('Blocks route /blocks loads', async ({ page }) => {
await page.goto(`${EXPLORER_URL}/blocks`, { waitUntil: 'load', timeout: 20000 });
await expect(page).toHaveURL(/\/blocks/, { timeout: 5000 });
await expect(page.getByText('Block').first()).toBeVisible({ timeout: 8000 });
});
test('Transactions route /transactions loads', async ({ page }) => {
await page.goto(`${EXPLORER_URL}/transactions`, { waitUntil: 'load', timeout: 20000 });
await expect(page).toHaveURL(/\/transactions/, { timeout: 5000 });
await expect(page.getByText('Transaction').first()).toBeVisible({ timeout: 8000 });
});
test('Bridge route /bridge has correct URL', async ({ page }) => {
await page.goto(`${EXPLORER_URL}/bridge`, { waitUntil: 'load', timeout: 20000 });
await expect(page).toHaveURL(/\/bridge/, { timeout: 5000 });
});
test('WETH route /weth has correct URL', async ({ page }) => {
await page.goto(`${EXPLORER_URL}/weth`, { waitUntil: 'load', timeout: 20000 });
await expect(page).toHaveURL(/\/weth/, { timeout: 5000 });
});
test('MetaMask Snap link has correct href', async ({ page }) => {
await page.goto(EXPLORER_URL, { waitUntil: 'domcontentloaded', timeout: 20000 });
const snapLink = page.locator('a[href="/snap/"]').first();
await expect(snapLink).toBeVisible({ timeout: 8000 });
await expect(snapLink).toHaveAttribute('href', '/snap/');
});
});
test.describe('Explorer Frontend - Breadcrumbs & Detail Links', () => {
test('Address page breadcrumb links work', async ({ page }) => {
await page.goto(`${EXPLORER_URL}/address/${ADDRESS_TEST}`, { waitUntil: 'networkidle', timeout: 15000 });
await page.waitForSelector('#addressDetailBreadcrumb', { state: 'attached', timeout: 15000 });
const homeLink = page.locator('#addressDetailBreadcrumb a[href="/home"], #addressDetailBreadcrumb a[href="#/home"]').first();
if (await homeLink.isVisible({ timeout: 2000 }).catch(() => false)) {
await homeLink.click();
await page.waitForTimeout(500);
expect(page.url()).toContain('home');
}
});
test('Block number link from list opens block detail', async ({ page }) => {
await page.goto(`${EXPLORER_URL}/blocks`, { waitUntil: 'networkidle', timeout: 15000 });
const blockLink = page.locator('a[href^="#/block/"], [onclick*="showBlockDetail"]').first();
if (await blockLink.isVisible({ timeout: 3000 })) {
await blockLink.click();
await page.waitForTimeout(1000);
await expect(page.locator('#blockDetail, .block-detail')).toBeVisible({ timeout: 3000 });
}
});
});
test('Addresses list opens address detail view', async ({ page }) => {
await page.goto(`${EXPLORER_URL}/addresses`, { waitUntil: 'networkidle', timeout: 20000 })
const addressLink = page.locator('[onclick*="showAddressDetail"]').first()
await expect(addressLink).toBeVisible({ timeout: 8000 })
await addressLink.click()
await expect(page.locator('#addressDetailView.active')).toBeVisible({ timeout: 8000 })
await expect(page.locator('#addressDetailBreadcrumb')).toBeVisible({ timeout: 8000 })
})
})