diff --git a/backend/api/rest/config/metamask/DUAL_CHAIN_NETWORKS.json b/backend/api/rest/config/metamask/DUAL_CHAIN_NETWORKS.json index b653317..60e9c04 100644 --- a/backend/api/rest/config/metamask/DUAL_CHAIN_NETWORKS.json +++ b/backend/api/rest/config/metamask/DUAL_CHAIN_NETWORKS.json @@ -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"]}, diff --git a/backend/api/rest/config/metamask/DUAL_CHAIN_TOKEN_LIST.tokenlist.json b/backend/api/rest/config/metamask/DUAL_CHAIN_TOKEN_LIST.tokenlist.json index 3dfb7d6..b15c032 100644 --- a/backend/api/rest/config/metamask/DUAL_CHAIN_TOKEN_LIST.tokenlist.json +++ b/backend/api/rest/config/metamask/DUAL_CHAIN_TOKEN_LIST.tokenlist.json @@ -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, diff --git a/backend/api/rest/config_test.go b/backend/api/rest/config_test.go new file mode 100644 index 0000000..4d5c921 --- /dev/null +++ b/backend/api/rest/config_test.go @@ -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") + } +} diff --git a/frontend/src/components/wallet/AddToMetaMask.tsx b/frontend/src/components/wallet/AddToMetaMask.tsx index f205fbd..ce9b7ff 100644 --- a/frontend/src/components/wallet/AddToMetaMask.tsx +++ b/frontend/src/components/wallet/AddToMetaMask.tsx @@ -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 +} + +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 +} + +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(null) const [error, setError] = useState(null) + const [networks, setNetworks] = useState(null) + const [tokenList, setTokenList] = useState(null) - const ethereum = typeof window !== 'undefined' ? (window as unknown as { ethereum?: { request: (args: { method: string; params: unknown[] }) => Promise } }).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() + 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() + 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 ( -
+

Add to MetaMask

- 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.

-
+ +
-
-

Token list URL (add in MetaMask Settings → Token lists):

- {tokenListUrl} + +
+
+
Explorer-served MetaMask metadata
+
+

Networks catalog: {chains.total > 0 ? `${chains.total} chains` : 'using frontend fallback values'}

+

Chain 138 token entries: {tokenCount138}

+ {metadataKeywordString ?

Keywords: {metadataKeywordString}

: null} +
+
+
+

Networks config URL

+ {networksUrl} +
+ + + Open JSON + +
+
+
+

Token list URL

+ {tokenListUrl} +
+ + + Open JSON + +
+
+
+
+ +
+
Featured Chain 138 tokens
+

+ 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. +

+
+ {featuredTokens.length === 0 ? ( +

Featured token metadata is not available right now.

+ ) : featuredTokens.map((token) => ( +
+
+
+
+ {token.symbol} ({token.name}) +
+
+ {token.address} +
+
+ Decimals: {token.decimals} + {token.tags?.length ? ` • Tags: ${token.tags.join(', ')}` : ''} +
+ {typeof token.extensions?.unitDescription === 'string' ? ( +
{token.extensions.unitDescription}
+ ) : null} +
+ +
+
+ ))} +
+
- {status &&

{status}

} - {error &&

{error}

} + + {status ?

{status}

: null} + {error ?

{error}

: null}
) } diff --git a/frontend/src/pages/addresses/[address].tsx b/frontend/src/pages/addresses/[address].tsx index 39718b5..f7ef108 100644 --- a/frontend/src/pages/addresses/[address].tsx +++ b/frontend/src/pages/addresses/[address].tsx @@ -115,6 +115,15 @@ export default function AddressDetailPage() { {addressInfo.label || 'Address'} +
+ + Back to addresses + + + Search this address + +
+
diff --git a/frontend/src/pages/blocks/[number].tsx b/frontend/src/pages/blocks/[number].tsx index 8bf23ee..9cb8a94 100644 --- a/frontend/src/pages/blocks/[number].tsx +++ b/frontend/src/pages/blocks/[number].tsx @@ -60,6 +60,20 @@ export default function BlockDetailPage() {

Block #{block.number}

+
+ + Back to blocks + + {block.number > 0 ? ( + + Previous block + + ) : null} + + Next block + +
+
diff --git a/frontend/src/pages/transactions/[hash].tsx b/frontend/src/pages/transactions/[hash].tsx index be18a39..d45750b 100644 --- a/frontend/src/pages/transactions/[hash].tsx +++ b/frontend/src/pages/transactions/[hash].tsx @@ -61,6 +61,15 @@ export default function TransactionDetailPage() {

Transaction

+
+ + Back to transactions + + + Search this hash + +
+
diff --git a/scripts/e2e-explorer-frontend.spec.ts b/scripts/e2e-explorer-frontend.spec.ts index 081e209..66224ea 100644 --- a/scripts/e2e-explorer-frontend.spec.ts +++ b/scripts/e2e-explorer-frontend.spec.ts @@ -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[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 }) + }) +})