Some checks failed
Deploy Explorer Live / deploy (push) Failing after 14s
Unify wallet/catalog/extended token-list policy, add contract verification CTA, trim the homepage dashboard with status strip and recent activity, and add Playwright smoke coverage. Co-authored-by: Cursor <cursoragent@cursor.com>
988 lines
38 KiB
TypeScript
988 lines
38 KiB
TypeScript
'use client'
|
||
|
||
import { useEffect, useMemo, useState } from 'react'
|
||
import { resolveExplorerApiBase } from '@/libs/frontend-api-client/api-base'
|
||
import { tokensApi } from '@/services/api/tokens'
|
||
import { selectWalletFeaturedTokens } from '@/utils/featuredTokens'
|
||
|
||
export type WalletChain = {
|
||
chainId: string
|
||
chainIdDecimal?: number
|
||
chainName: string
|
||
rpcUrls: string[]
|
||
blockExplorerUrls: string[]
|
||
iconUrls?: string[]
|
||
nativeCurrency: {
|
||
name: string
|
||
symbol: string
|
||
decimals: number
|
||
}
|
||
shortName?: string
|
||
infoURL?: string
|
||
explorerApiUrl?: string
|
||
}
|
||
|
||
export type TokenListToken = {
|
||
chainId: number
|
||
address: string
|
||
name: string
|
||
symbol: string
|
||
decimals: number
|
||
logoURI?: string
|
||
tags?: string[]
|
||
extensions?: Record<string, unknown>
|
||
}
|
||
|
||
export type NetworksCatalog = {
|
||
name?: string
|
||
version?: {
|
||
major?: number
|
||
minor?: number
|
||
patch?: number
|
||
}
|
||
defaultChainId?: number
|
||
chains?: WalletChain[]
|
||
}
|
||
|
||
export type TokenListCatalog = {
|
||
name?: string
|
||
version?: {
|
||
major?: number
|
||
minor?: number
|
||
patch?: number
|
||
}
|
||
keywords?: string[]
|
||
tokens?: TokenListToken[]
|
||
}
|
||
|
||
export type CapabilitiesCatalog = {
|
||
name?: string
|
||
version?: {
|
||
major?: number
|
||
minor?: number
|
||
patch?: number
|
||
}
|
||
timestamp?: string
|
||
chainId?: number
|
||
chainName?: string
|
||
rpcUrl?: string
|
||
explorerUrl?: string
|
||
explorerApiUrl?: string
|
||
generatedBy?: string
|
||
walletSupport?: {
|
||
walletAddEthereumChain?: boolean
|
||
walletWatchAsset?: boolean
|
||
notes?: string[]
|
||
}
|
||
http?: {
|
||
supportedMethods?: string[]
|
||
unsupportedMethods?: string[]
|
||
notes?: string[]
|
||
}
|
||
tracing?: {
|
||
supportedMethods?: string[]
|
||
unsupportedMethods?: string[]
|
||
notes?: string[]
|
||
}
|
||
}
|
||
|
||
type WatchAssetEntry = {
|
||
type: 'ERC20'
|
||
options: {
|
||
address: string
|
||
symbol: string
|
||
decimals: number
|
||
image?: string
|
||
}
|
||
metadata?: {
|
||
name?: string
|
||
registryFamily?: string
|
||
familySymbol?: string
|
||
deploymentVersion?: string
|
||
deploymentStatus?: string
|
||
}
|
||
}
|
||
|
||
type MetaMaskConfig = {
|
||
source?: string
|
||
version?: string
|
||
chainId?: number
|
||
addEthereumChain?: WalletChain
|
||
watchAssets?: WatchAssetEntry[]
|
||
caveats?: string[]
|
||
}
|
||
|
||
export type FetchMetadata = {
|
||
source?: string | null
|
||
lastModified?: string | null
|
||
}
|
||
|
||
interface AddToMetaMaskProps {
|
||
initialNetworks?: NetworksCatalog | null
|
||
initialTokenList?: TokenListCatalog | null
|
||
initialCapabilities?: CapabilitiesCatalog | null
|
||
initialNetworksMeta?: FetchMetadata | null
|
||
initialTokenListMeta?: FetchMetadata | null
|
||
initialCapabilitiesMeta?: FetchMetadata | null
|
||
}
|
||
|
||
type EthereumProvider = {
|
||
request: (args: { method: string; params?: unknown }) => Promise<unknown>
|
||
}
|
||
|
||
const FALLBACK_CHAIN_138: WalletChain = {
|
||
chainId: '0x8a',
|
||
chainIdDecimal: 138,
|
||
chainName: 'DeFi Oracle Meta Mainnet',
|
||
nativeCurrency: { name: 'Ether', symbol: 'ETH', decimals: 18 },
|
||
rpcUrls: ['https://rpc-http-pub.d-bis.org', 'https://rpc.d-bis.org', 'https://rpc2.d-bis.org'],
|
||
blockExplorerUrls: ['https://explorer.d-bis.org', 'https://blockscout.defi-oracle.io'],
|
||
iconUrls: [
|
||
'https://explorer.d-bis.org/api/v1/report/logo/chain-138',
|
||
'https://explorer.d-bis.org/token-icons/chain-138.png',
|
||
'https://explorer.d-bis.org/favicon.ico',
|
||
],
|
||
shortName: 'dbis',
|
||
infoURL: 'https://explorer.d-bis.org',
|
||
explorerApiUrl: 'https://explorer.d-bis.org/api/v2',
|
||
}
|
||
|
||
const FALLBACK_ETHEREUM: WalletChain = {
|
||
chainId: '0x1',
|
||
chainIdDecimal: 1,
|
||
chainName: 'Ethereum Mainnet',
|
||
nativeCurrency: { name: 'Ether', symbol: 'ETH', decimals: 18 },
|
||
rpcUrls: ['https://eth.llamarpc.com', 'https://rpc.ankr.com/eth', 'https://ethereum.publicnode.com'],
|
||
blockExplorerUrls: ['https://etherscan.io'],
|
||
iconUrls: ['https://raw.githubusercontent.com/ethereum/ethereum.org/main/static/images/eth-diamond-black.png'],
|
||
shortName: 'eth',
|
||
infoURL: 'https://ethereum.org',
|
||
}
|
||
|
||
const FALLBACK_ALL_MAINNET: WalletChain = {
|
||
chainId: '0x9f2c4',
|
||
chainIdDecimal: 651940,
|
||
chainName: 'ALL Mainnet',
|
||
nativeCurrency: { name: 'Ether', symbol: 'ETH', decimals: 18 },
|
||
rpcUrls: ['https://mainnet-rpc.alltra.global'],
|
||
blockExplorerUrls: ['https://alltra.global'],
|
||
iconUrls: ['https://raw.githubusercontent.com/ethereum/ethereum.org/main/static/images/eth-diamond-black.png'],
|
||
shortName: 'all',
|
||
infoURL: 'https://alltra.global',
|
||
}
|
||
|
||
const MAINNET_CWUSDC_TOKEN: TokenListToken = {
|
||
chainId: 1,
|
||
address: '0x2de5F116bFcE3d0f922d9C8351e0c5Fc24b9284a',
|
||
symbol: 'cWUSDC',
|
||
name: 'Wrapped cUSDC',
|
||
decimals: 6,
|
||
logoURI: 'https://explorer.d-bis.org/api/v1/report/logo/cUSDC?v=20260510',
|
||
tags: ['mainnet', 'cw', 'usd'],
|
||
extensions: {
|
||
registryFamily: 'iso4217',
|
||
familySymbol: 'USD',
|
||
canonicalSourceChainId: 138,
|
||
canonicalSourceSymbol: 'cUSDC',
|
||
},
|
||
}
|
||
|
||
/** npm-published Snap using open Snap permissions only; stable MetaMask still requires MetaMask’s install allowlist. */
|
||
const CHAIN138_OPEN_SNAP_ID = 'npm:chain138-open-snap' as const
|
||
|
||
const FALLBACK_CAPABILITIES_138: CapabilitiesCatalog = {
|
||
name: 'Chain 138 RPC Capabilities',
|
||
version: { major: 1, minor: 1, patch: 0 },
|
||
timestamp: '2026-03-28T00:00:00Z',
|
||
generatedBy: 'DBIS Explorer',
|
||
chainId: 138,
|
||
chainName: 'DeFi Oracle Meta Mainnet',
|
||
rpcUrl: 'https://rpc-http-pub.d-bis.org',
|
||
explorerUrl: 'https://explorer.d-bis.org',
|
||
explorerApiUrl: 'https://explorer.d-bis.org/api/v2',
|
||
walletSupport: {
|
||
walletAddEthereumChain: true,
|
||
walletWatchAsset: true,
|
||
notes: [
|
||
'MetaMask primarily relies on JSON-RPC correctness for balances, gas estimation, calls, and transaction submission.',
|
||
'Explorer-served network metadata and token list metadata complement wallet UX but do not replace RPC method support.',
|
||
],
|
||
},
|
||
http: {
|
||
supportedMethods: [
|
||
'web3_clientVersion',
|
||
'net_version',
|
||
'eth_chainId',
|
||
'eth_blockNumber',
|
||
'eth_syncing',
|
||
'eth_gasPrice',
|
||
'eth_maxPriorityFeePerGas',
|
||
'eth_feeHistory',
|
||
'eth_estimateGas',
|
||
'eth_getCode',
|
||
],
|
||
unsupportedMethods: [],
|
||
notes: [
|
||
'eth_feeHistory is available for wallet fee estimation.',
|
||
'eth_maxPriorityFeePerGas is exposed on the public RPC for wallet-grade fee suggestion compatibility.',
|
||
],
|
||
},
|
||
tracing: {
|
||
supportedMethods: ['trace_block', 'trace_replayBlockTransactions'],
|
||
unsupportedMethods: ['debug_traceBlockByNumber'],
|
||
notes: [
|
||
'TRACE support is enabled for explorer-grade indexing and internal transaction analysis.',
|
||
'Debug tracing is intentionally not enabled on the public RPC tier.',
|
||
],
|
||
},
|
||
}
|
||
|
||
function isTokenListToken(value: unknown): value is TokenListToken {
|
||
if (!value || typeof value !== 'object') return false
|
||
|
||
const candidate = value as Partial<TokenListToken>
|
||
return (
|
||
typeof candidate.chainId === 'number' &&
|
||
typeof candidate.address === 'string' &&
|
||
candidate.address.trim().length > 0 &&
|
||
typeof candidate.name === 'string' &&
|
||
typeof candidate.symbol === 'string' &&
|
||
typeof candidate.decimals === 'number'
|
||
)
|
||
}
|
||
|
||
function isCapabilitiesCatalog(value: unknown): value is CapabilitiesCatalog {
|
||
if (!value || typeof value !== 'object') return false
|
||
|
||
const candidate = value as Partial<CapabilitiesCatalog>
|
||
return (
|
||
typeof candidate.chainId === 'number' &&
|
||
typeof candidate.chainName === 'string' &&
|
||
candidate.chainName.trim().length > 0 &&
|
||
typeof candidate.rpcUrl === 'string' &&
|
||
candidate.rpcUrl.trim().length > 0
|
||
)
|
||
}
|
||
|
||
function isWatchAssetEntry(value: unknown): value is WatchAssetEntry {
|
||
if (!value || typeof value !== 'object') return false
|
||
|
||
const candidate = value as Partial<WatchAssetEntry>
|
||
const options = (candidate.options || {}) as Partial<WatchAssetEntry['options']>
|
||
return (
|
||
candidate.type === 'ERC20' &&
|
||
typeof options.address === 'string' &&
|
||
options.address.trim().length > 0 &&
|
||
typeof options.symbol === 'string' &&
|
||
options.symbol.trim().length > 0 &&
|
||
typeof options.decimals === 'number'
|
||
)
|
||
}
|
||
|
||
function isMetaMaskConfig(value: unknown): value is MetaMaskConfig {
|
||
if (!value || typeof value !== 'object') return false
|
||
|
||
const candidate = value as Partial<MetaMaskConfig>
|
||
return (
|
||
typeof candidate.chainId === 'number' &&
|
||
!!candidate.addEthereumChain &&
|
||
Array.isArray(candidate.watchAssets)
|
||
)
|
||
}
|
||
|
||
function watchAssetToToken(entry: WatchAssetEntry): TokenListToken {
|
||
return {
|
||
chainId: 138,
|
||
address: entry.options.address,
|
||
symbol: entry.options.symbol,
|
||
name: entry.metadata?.name || entry.options.symbol,
|
||
decimals: entry.options.decimals,
|
||
logoURI: entry.options.image,
|
||
extensions: {
|
||
registryFamily: entry.metadata?.registryFamily,
|
||
familySymbol: entry.metadata?.familySymbol,
|
||
deploymentVersion: entry.metadata?.deploymentVersion,
|
||
deploymentStatus: entry.metadata?.deploymentStatus,
|
||
},
|
||
}
|
||
}
|
||
|
||
function getApiBase() {
|
||
return resolveExplorerApiBase({
|
||
browserOrigin: '',
|
||
serverFallback: 'https://explorer.d-bis.org',
|
||
})
|
||
}
|
||
|
||
function formatStableTimestamp(value: string): string {
|
||
const timestamp = Date.parse(value)
|
||
if (Number.isNaN(timestamp)) return value
|
||
return new Date(timestamp).toISOString()
|
||
}
|
||
|
||
export function AddToMetaMask({
|
||
initialNetworks = null,
|
||
initialTokenList = null,
|
||
initialCapabilities = null,
|
||
initialNetworksMeta = null,
|
||
initialTokenListMeta = null,
|
||
initialCapabilitiesMeta = null,
|
||
}: AddToMetaMaskProps) {
|
||
const [status, setStatus] = useState<string | null>(null)
|
||
const [error, setError] = useState<string | null>(null)
|
||
const [networks, setNetworks] = useState<NetworksCatalog | null>(initialNetworks)
|
||
const [tokenList, setTokenList] = useState<TokenListCatalog | null>(initialTokenList)
|
||
const [capabilities, setCapabilities] = useState<CapabilitiesCatalog | null>(
|
||
initialCapabilities || FALLBACK_CAPABILITIES_138,
|
||
)
|
||
const [networksMeta, setNetworksMeta] = useState<FetchMetadata | null>(initialNetworksMeta)
|
||
const [tokenListMeta, setTokenListMeta] = useState<FetchMetadata | null>(initialTokenListMeta)
|
||
const [capabilitiesMeta, setCapabilitiesMeta] = useState<FetchMetadata | null>(
|
||
initialCapabilitiesMeta ||
|
||
(initialCapabilities
|
||
? {
|
||
source: 'explorer-api',
|
||
lastModified: initialCapabilities.timestamp || null,
|
||
}
|
||
: {
|
||
source: 'frontend-fallback',
|
||
lastModified: FALLBACK_CAPABILITIES_138.timestamp || null,
|
||
}),
|
||
)
|
||
const [metamaskConfig, setMetamaskConfig] = useState<MetaMaskConfig | null>(null)
|
||
const [metamaskConfigMeta, setMetamaskConfigMeta] = useState<FetchMetadata | null>(null)
|
||
const [curatedTokens, setCuratedTokens] = useState<TokenListToken[]>([])
|
||
const [watchAssetProgress, setWatchAssetProgress] = useState<{ current: number; total: number } | null>(null)
|
||
|
||
const ethereum = typeof window !== 'undefined'
|
||
? (window as unknown as { ethereum?: EthereumProvider }).ethereum
|
||
: undefined
|
||
|
||
const apiBase = getApiBase().replace(/\/$/, '')
|
||
const tokenListUrl = `${apiBase}/api/v1/report/token-list?chainId=138`
|
||
const networksUrl = `${apiBase}/api/config/networks`
|
||
const metamaskConfigUrl = `${apiBase}/api/v1/config/metamask?chainId=138`
|
||
const capabilitiesUrl = `${apiBase}/api/config/capabilities`
|
||
const staticCapabilitiesUrl = `${apiBase}/config/CHAIN138_RPC_CAPABILITIES.json`
|
||
|
||
useEffect(() => {
|
||
let active = true
|
||
let timer: ReturnType<typeof setTimeout> | null = null
|
||
|
||
async function fetchJson(url: string) {
|
||
const response = await fetch(url, {
|
||
cache: 'no-store',
|
||
headers: {
|
||
'Cache-Control': 'no-cache',
|
||
},
|
||
})
|
||
const json = response.ok ? await response.json() : null
|
||
const meta: FetchMetadata = {
|
||
source: response.headers.get('X-Config-Source') || 'explorer-api',
|
||
lastModified: response.headers.get('Last-Modified'),
|
||
}
|
||
return { json, meta }
|
||
}
|
||
|
||
async function loadCatalogs() {
|
||
try {
|
||
const [networksResponse, tokenListResponse, capabilitiesResponse] = await Promise.all([
|
||
fetchJson(networksUrl),
|
||
fetchJson(tokenListUrl),
|
||
fetchJson(capabilitiesUrl),
|
||
])
|
||
const metamaskConfigResponse = await fetchJson(metamaskConfigUrl).catch(() => null)
|
||
|
||
let resolvedCapabilities = capabilitiesResponse
|
||
if (!isCapabilitiesCatalog(resolvedCapabilities.json)) {
|
||
const staticCapabilitiesResponse = await fetchJson(staticCapabilitiesUrl)
|
||
if (isCapabilitiesCatalog(staticCapabilitiesResponse.json)) {
|
||
resolvedCapabilities = {
|
||
json: staticCapabilitiesResponse.json,
|
||
meta: {
|
||
source: staticCapabilitiesResponse.meta.source || 'public-static-fallback',
|
||
lastModified: staticCapabilitiesResponse.meta.lastModified,
|
||
},
|
||
}
|
||
} else {
|
||
resolvedCapabilities = {
|
||
json: FALLBACK_CAPABILITIES_138,
|
||
meta: {
|
||
source: 'frontend-fallback',
|
||
lastModified: FALLBACK_CAPABILITIES_138.timestamp || null,
|
||
},
|
||
}
|
||
}
|
||
}
|
||
|
||
if (!active) return
|
||
setNetworks(networksResponse.json)
|
||
setTokenList(tokenListResponse.json)
|
||
setCapabilities(resolvedCapabilities.json)
|
||
if (isMetaMaskConfig(metamaskConfigResponse?.json)) {
|
||
setMetamaskConfig(metamaskConfigResponse.json)
|
||
setMetamaskConfigMeta(metamaskConfigResponse.meta)
|
||
}
|
||
setNetworksMeta(networksResponse.meta)
|
||
setTokenListMeta(tokenListResponse.meta)
|
||
setCapabilitiesMeta(resolvedCapabilities.meta)
|
||
} catch {
|
||
if (!active) return
|
||
setNetworks((current) => current)
|
||
setTokenList((current) => current)
|
||
setCapabilities((current) => current || FALLBACK_CAPABILITIES_138)
|
||
setMetamaskConfig((current) => current)
|
||
setNetworksMeta((current) => current)
|
||
setTokenListMeta((current) => current)
|
||
setCapabilitiesMeta((current) =>
|
||
current || {
|
||
source: 'frontend-fallback',
|
||
lastModified: FALLBACK_CAPABILITIES_138.timestamp || null,
|
||
},
|
||
)
|
||
} finally {
|
||
if (active) {
|
||
timer = setTimeout(() => {
|
||
void loadCatalogs()
|
||
}, 60_000)
|
||
}
|
||
}
|
||
}
|
||
|
||
void loadCatalogs()
|
||
|
||
return () => {
|
||
active = false
|
||
if (timer) clearTimeout(timer)
|
||
}
|
||
}, [capabilitiesUrl, metamaskConfigUrl, networksUrl, staticCapabilitiesUrl, tokenListUrl])
|
||
|
||
useEffect(() => {
|
||
let active = true
|
||
|
||
tokensApi.listForSurface('wallet', 138).then(({ ok, data }) => {
|
||
if (active) {
|
||
setCuratedTokens(ok ? (data as TokenListToken[]) : [])
|
||
}
|
||
}).catch(() => {
|
||
if (active) {
|
||
setCuratedTokens([])
|
||
}
|
||
})
|
||
|
||
return () => {
|
||
active = false
|
||
}
|
||
}, [])
|
||
|
||
const catalogTokens = useMemo(
|
||
() => (Array.isArray(tokenList?.tokens) ? tokenList.tokens.filter(isTokenListToken) : []),
|
||
[tokenList],
|
||
)
|
||
|
||
const chains = useMemo(() => {
|
||
const chainMap = new Map<number, WalletChain>()
|
||
for (const chain of networks?.chains || []) {
|
||
if (typeof chain.chainIdDecimal === 'number') {
|
||
chainMap.set(chain.chainIdDecimal, chain)
|
||
}
|
||
}
|
||
|
||
return {
|
||
chain138: metamaskConfig?.addEthereumChain || chainMap.get(138) || FALLBACK_CHAIN_138,
|
||
ethereum: chainMap.get(1) || FALLBACK_ETHEREUM,
|
||
allMainnet: chainMap.get(651940) || FALLBACK_ALL_MAINNET,
|
||
total: (networks?.chains || []).length,
|
||
}
|
||
}, [metamaskConfig, networks])
|
||
|
||
const featuredTokens = useMemo(
|
||
() => selectWalletFeaturedTokens(catalogTokens, curatedTokens) as TokenListToken[],
|
||
[catalogTokens, curatedTokens],
|
||
)
|
||
|
||
const watchAssetTokens = useMemo(() => {
|
||
const endpointTokens = (metamaskConfig?.watchAssets || [])
|
||
.filter(isWatchAssetEntry)
|
||
.map(watchAssetToToken)
|
||
|
||
if (endpointTokens.length > 0) return endpointTokens
|
||
return catalogTokens.filter((token) => token.chainId === 138)
|
||
}, [catalogTokens, metamaskConfig])
|
||
|
||
const addChain = async (chain: WalletChain) => {
|
||
setError(null)
|
||
setStatus(null)
|
||
|
||
if (!ethereum) {
|
||
setError('MetaMask or another Web3 wallet is not installed.')
|
||
return
|
||
}
|
||
|
||
try {
|
||
await ethereum.request({
|
||
method: 'wallet_addEthereumChain',
|
||
params: [chain],
|
||
})
|
||
setStatus(`Added ${chain.chainName}. You can switch to it in your wallet.`)
|
||
} catch (e) {
|
||
const err = e as { code?: number; message?: string }
|
||
if (err.code === 4902) {
|
||
setStatus(`Added ${chain.chainName}. You can switch to it in your wallet.`)
|
||
} else {
|
||
setError(err.message || `Failed to add ${chain.chainName}.`)
|
||
}
|
||
}
|
||
}
|
||
|
||
const switchOrAddChain = async (chain: WalletChain) => {
|
||
if (!ethereum) {
|
||
setError('MetaMask or another Web3 wallet is not installed.')
|
||
return false
|
||
}
|
||
|
||
try {
|
||
await ethereum.request({
|
||
method: 'wallet_switchEthereumChain',
|
||
params: [{ chainId: chain.chainId }],
|
||
})
|
||
return true
|
||
} catch (e) {
|
||
const err = e as { code?: number; message?: string }
|
||
if (err.code !== 4902) {
|
||
setError(err.message || `Failed to switch to ${chain.chainName}.`)
|
||
return false
|
||
}
|
||
}
|
||
|
||
try {
|
||
await ethereum.request({
|
||
method: 'wallet_addEthereumChain',
|
||
params: [chain],
|
||
})
|
||
return true
|
||
} catch (e) {
|
||
const err = e as { message?: string }
|
||
setError(err.message || `Failed to add ${chain.chainName}.`)
|
||
return false
|
||
}
|
||
}
|
||
|
||
const installOpenSnap = async () => {
|
||
setError(null)
|
||
setStatus(null)
|
||
|
||
if (!ethereum) {
|
||
setError('MetaMask or another Web3 wallet is not installed.')
|
||
return
|
||
}
|
||
|
||
try {
|
||
await ethereum.request({
|
||
method: 'wallet_requestSnaps',
|
||
params: { [CHAIN138_OPEN_SNAP_ID]: {} },
|
||
})
|
||
setStatus(
|
||
`Installed or connected to ${CHAIN138_OPEN_SNAP_ID}. In MetaMask, open Snaps → Chain 138 Open for the home page (token list URL, network info).`,
|
||
)
|
||
} catch (e) {
|
||
const err = e as { message?: string }
|
||
const msg = err.message || ''
|
||
const allowlistBlocked = /allowlist/i.test(msg)
|
||
if (allowlistBlocked && msg) {
|
||
setError(
|
||
`${msg} This is expected on Stable MetaMask until this exact Snap package and version are accepted on MetaMask's install allowlist. The production path on this page is Add Chain 138 plus EIP-747 Add Tokens; use MetaMask Flask for Snap testing or submit/update the Snap allowlist request before using this button with Stable MetaMask.`,
|
||
)
|
||
} else {
|
||
setError(
|
||
msg ||
|
||
`Could not install Snap. Enable MetaMask Snaps and ensure ${CHAIN138_OPEN_SNAP_ID} is published on npm.`,
|
||
)
|
||
}
|
||
}
|
||
}
|
||
|
||
const watchToken = async (token: TokenListToken) => {
|
||
setError(null)
|
||
setStatus(null)
|
||
|
||
if (!isTokenListToken(token)) {
|
||
setError('Token metadata is incomplete right now. Refresh the page and try again.')
|
||
return
|
||
}
|
||
|
||
if (!ethereum) {
|
||
setError('MetaMask or another Web3 wallet is not installed.')
|
||
return
|
||
}
|
||
|
||
try {
|
||
const added = await ethereum.request({
|
||
method: 'wallet_watchAsset',
|
||
params: {
|
||
type: 'ERC20',
|
||
options: {
|
||
address: token.address,
|
||
symbol: token.symbol,
|
||
decimals: token.decimals,
|
||
image: token.logoURI,
|
||
},
|
||
},
|
||
})
|
||
|
||
setStatus(added ? `Added ${token.symbol} to your wallet.` : `${token.symbol} request was dismissed.`)
|
||
} catch (e) {
|
||
const err = e as { message?: string }
|
||
setError(err.message || `Failed to add ${token.symbol}.`)
|
||
}
|
||
}
|
||
|
||
const refreshMainnetCwusdc = async () => {
|
||
setError(null)
|
||
setStatus(null)
|
||
|
||
const switched = await switchOrAddChain(chains.ethereum)
|
||
if (!switched) return
|
||
|
||
await watchToken(MAINNET_CWUSDC_TOKEN)
|
||
}
|
||
|
||
const watchTokensSequentially = async (tokens: TokenListToken[], label: string) => {
|
||
setError(null)
|
||
setStatus(null)
|
||
setWatchAssetProgress(null)
|
||
|
||
if (!ethereum) {
|
||
setError('MetaMask or another Web3 wallet is not installed.')
|
||
return
|
||
}
|
||
|
||
const validTokens = tokens.filter(isTokenListToken)
|
||
if (validTokens.length === 0) {
|
||
setError('No complete token metadata is available for wallet_watchAsset right now.')
|
||
return
|
||
}
|
||
|
||
let addedCount = 0
|
||
for (let index = 0; index < validTokens.length; index += 1) {
|
||
const token = validTokens[index]
|
||
setWatchAssetProgress({ current: index + 1, total: validTokens.length })
|
||
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,
|
||
},
|
||
},
|
||
})
|
||
if (added) addedCount += 1
|
||
} catch (e) {
|
||
const err = e as { message?: string }
|
||
setError(err.message || `Stopped while adding ${token.symbol}.`)
|
||
setStatus(`${addedCount} of ${validTokens.length} ${label} token requests were accepted before the flow stopped.`)
|
||
setWatchAssetProgress(null)
|
||
return
|
||
}
|
||
}
|
||
|
||
setWatchAssetProgress(null)
|
||
setStatus(`${addedCount} of ${validTokens.length} ${label} token requests were accepted by the wallet.`)
|
||
}
|
||
|
||
const copyText = async (value: string, label: string) => {
|
||
setError(null)
|
||
setStatus(null)
|
||
|
||
try {
|
||
await navigator.clipboard.writeText(value)
|
||
setStatus(`Copied ${label}.`)
|
||
} catch {
|
||
setError(`Could not copy ${label}.`)
|
||
}
|
||
}
|
||
|
||
const tokenCount138 = catalogTokens.filter((token) => token.chainId === 138).length
|
||
const metadataKeywordString = (tokenList?.keywords || []).join(', ')
|
||
const supportedHTTPMethods = capabilities?.http?.supportedMethods || []
|
||
const unsupportedHTTPMethods = capabilities?.http?.unsupportedMethods || []
|
||
const supportedTraceMethods = capabilities?.tracing?.supportedMethods || []
|
||
const displayedCapabilitiesUrl =
|
||
capabilitiesMeta?.source === 'public-static-fallback' || capabilitiesMeta?.source === 'frontend-fallback'
|
||
? staticCapabilitiesUrl
|
||
: capabilitiesUrl
|
||
|
||
return (
|
||
<div className="space-y-4 rounded-xl border border-gray-200 bg-white p-4 dark:border-gray-700 dark:bg-gray-800 sm:p-5">
|
||
<h2 className="text-lg font-semibold">Add to MetaMask</h2>
|
||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||
The wallet tools now read the same explorer-served network catalog and token list that MetaMask can consume.
|
||
That keeps chain metadata, token metadata, and optional extensions aligned with the live explorer API instead of
|
||
relying on stale frontend-only defaults. MetaMask does not run built-in token detection on custom networks such
|
||
as Chain 138, so this page uses EIP-747 wallet_watchAsset prompts from the live MetaMask payload to add token
|
||
metadata directly to the wallet.
|
||
</p>
|
||
|
||
<div className="grid gap-3 md:grid-cols-3">
|
||
<button
|
||
type="button"
|
||
onClick={() => addChain(chains.chain138)}
|
||
className="rounded bg-primary-600 px-4 py-2 text-sm font-medium text-white hover:bg-primary-700"
|
||
>
|
||
Add Chain 138
|
||
</button>
|
||
<button
|
||
type="button"
|
||
onClick={() => addChain(chains.ethereum)}
|
||
className="rounded bg-gray-600 px-4 py-2 text-sm font-medium text-white hover:bg-gray-700"
|
||
>
|
||
Add Ethereum Mainnet
|
||
</button>
|
||
<button
|
||
type="button"
|
||
onClick={() => addChain(chains.allMainnet)}
|
||
className="rounded bg-gray-600 px-4 py-2 text-sm font-medium text-white hover:bg-gray-700"
|
||
>
|
||
Add ALL Mainnet
|
||
</button>
|
||
</div>
|
||
|
||
<div className="rounded-lg border border-amber-200 bg-amber-50/50 p-4 dark:border-amber-900 dark:bg-amber-950/20">
|
||
<div className="text-sm font-semibold text-gray-900 dark:text-white">Optional Chain 138 Open Snap</div>
|
||
<p className="mt-2 text-sm text-gray-600 dark:text-gray-400">
|
||
This is <span className="font-medium text-gray-800 dark:text-gray-200">not required</span> for the production
|
||
wallet flow above. The normal production path is to add Chain 138, then add tokens through EIP-747
|
||
wallet_watchAsset prompts. The optional Snap uses{' '}
|
||
<span className="font-medium text-gray-800 dark:text-gray-200">only open Snap permissions</span> (minimal
|
||
privileged APIs in the Snap itself).{' '}
|
||
<span className="font-medium text-gray-800 dark:text-gray-200">Stable MetaMask</span> still only installs npm
|
||
Snaps that appear on MetaMask's install allowlist; if install fails with "not on the allowlist", that is
|
||
an external MetaMask review gate rather than an explorer/network failure. Use{' '}
|
||
<span className="font-medium text-gray-800 dark:text-gray-200">MetaMask Flask</span> for development or apply
|
||
for allowlisting before using this with Stable MetaMask. The package on npm is{' '}
|
||
<code className="break-all rounded bg-gray-100 px-1 text-xs dark:bg-gray-900">{CHAIN138_OPEN_SNAP_ID}</code>
|
||
— publish from the repo with <code className="break-all rounded bg-gray-100 px-1 text-xs dark:bg-gray-900">scripts/deployment/publish-chain138-open-snap.sh</code> after{' '}
|
||
<code className="rounded bg-gray-100 px-1 text-xs dark:bg-gray-900">npm login</code>.
|
||
</p>
|
||
<button
|
||
type="button"
|
||
onClick={() => void installOpenSnap()}
|
||
className="mt-3 rounded bg-amber-700 px-4 py-2 text-sm font-medium text-white hover:bg-amber-800"
|
||
>
|
||
Install Snap (Flask or allowlisted Stable)
|
||
</button>
|
||
</div>
|
||
|
||
<div className="grid gap-4 lg:grid-cols-2">
|
||
<div className="rounded-lg border border-gray-200 p-4 dark:border-gray-700">
|
||
<div className="text-sm font-semibold text-gray-900 dark:text-white">Explorer-served MetaMask metadata</div>
|
||
<div className="mt-3 space-y-2 text-sm text-gray-600 dark:text-gray-400">
|
||
<p>Networks catalog: {chains.total > 0 ? `${chains.total} chains` : 'using frontend fallback values'}</p>
|
||
<p>Chain 138 token entries: {tokenCount138}</p>
|
||
<p>EIP-747 watchAsset entries: {watchAssetTokens.length}</p>
|
||
<p>Networks source: {networksMeta?.source || 'unknown'}</p>
|
||
<p>Token list source: {tokenListMeta?.source || 'unknown'}</p>
|
||
<p>MetaMask payload source: {metamaskConfigMeta?.source || 'unknown'}</p>
|
||
{metadataKeywordString ? <p>Keywords: {metadataKeywordString}</p> : null}
|
||
</div>
|
||
<div className="mt-4 space-y-3">
|
||
<div>
|
||
<p className="mb-1 text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">Networks config URL</p>
|
||
<code className="block break-all rounded bg-gray-100 p-2 text-xs dark:bg-gray-900">{networksUrl}</code>
|
||
<div className="mt-2 flex flex-wrap gap-2">
|
||
<button type="button" onClick={() => copyText(networksUrl, 'networks config URL')} className="rounded bg-gray-100 px-3 py-1.5 text-xs text-gray-700 hover:bg-gray-200 dark:bg-gray-900 dark:text-gray-200 dark:hover:bg-gray-700">
|
||
Copy URL
|
||
</button>
|
||
<a href={networksUrl} target="_blank" rel="noopener noreferrer" className="rounded bg-gray-100 px-3 py-1.5 text-xs text-gray-700 hover:bg-gray-200 dark:bg-gray-900 dark:text-gray-200 dark:hover:bg-gray-700">
|
||
Open JSON
|
||
</a>
|
||
</div>
|
||
</div>
|
||
<div>
|
||
<p className="mb-1 text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">Capabilities URL</p>
|
||
<code className="block break-all rounded bg-gray-100 p-2 text-xs dark:bg-gray-900">{displayedCapabilitiesUrl}</code>
|
||
<div className="mt-2 flex flex-wrap gap-2">
|
||
<button type="button" onClick={() => copyText(displayedCapabilitiesUrl, 'capabilities URL')} className="rounded bg-gray-100 px-3 py-1.5 text-xs text-gray-700 hover:bg-gray-200 dark:bg-gray-900 dark:text-gray-200 dark:hover:bg-gray-700">
|
||
Copy URL
|
||
</button>
|
||
<a href={displayedCapabilitiesUrl} target="_blank" rel="noopener noreferrer" className="rounded bg-gray-100 px-3 py-1.5 text-xs text-gray-700 hover:bg-gray-200 dark:bg-gray-900 dark:text-gray-200 dark:hover:bg-gray-700">
|
||
Open JSON
|
||
</a>
|
||
</div>
|
||
</div>
|
||
<div>
|
||
<p className="mb-1 text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">EIP-747 MetaMask payload URL</p>
|
||
<code className="block break-all rounded bg-gray-100 p-2 text-xs dark:bg-gray-900">{metamaskConfigUrl}</code>
|
||
<div className="mt-2 flex flex-wrap gap-2">
|
||
<button type="button" onClick={() => copyText(metamaskConfigUrl, 'MetaMask payload 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={metamaskConfigUrl} target="_blank" rel="noopener noreferrer" className="rounded bg-gray-100 px-3 py-1.5 text-xs text-gray-700 hover:bg-gray-200 dark:bg-gray-900 dark:text-gray-200 dark:hover:bg-gray-700">
|
||
Open JSON
|
||
</a>
|
||
</div>
|
||
</div>
|
||
<div>
|
||
<p className="mb-1 text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">Token list URL</p>
|
||
<code className="block break-all rounded bg-gray-100 p-2 text-xs dark:bg-gray-900">{tokenListUrl}</code>
|
||
<div className="mt-2 flex flex-wrap gap-2">
|
||
<button type="button" onClick={() => copyText(tokenListUrl, 'token list URL')} className="rounded bg-gray-100 px-3 py-1.5 text-xs text-gray-700 hover:bg-gray-200 dark:bg-gray-900 dark:text-gray-200 dark:hover:bg-gray-700">
|
||
Copy URL
|
||
</button>
|
||
<a href={tokenListUrl} target="_blank" rel="noopener noreferrer" className="rounded bg-gray-100 px-3 py-1.5 text-xs text-gray-700 hover:bg-gray-200 dark:bg-gray-900 dark:text-gray-200 dark:hover:bg-gray-700">
|
||
Open JSON
|
||
</a>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="rounded-lg border border-gray-200 p-4 dark:border-gray-700">
|
||
<div className="text-sm font-semibold text-gray-900 dark:text-white">Chain 138 RPC capabilities</div>
|
||
<p className="mt-2 text-sm text-gray-600 dark:text-gray-400">
|
||
This capability matrix documents what public RPC methods wallets can rely on today, what tracing the explorer
|
||
can use, and where MetaMask will still fall back because the public node does not expose every optional fee method.
|
||
</p>
|
||
<div className="mt-4 space-y-3 text-sm text-gray-600 dark:text-gray-400">
|
||
<p>
|
||
RPC endpoint:{' '}
|
||
<span className="font-medium text-gray-900 dark:text-white">
|
||
{capabilities?.rpcUrl || 'using published explorer fallback'}
|
||
</span>
|
||
</p>
|
||
<p>
|
||
Capabilities source:{' '}
|
||
<span className="font-medium text-gray-900 dark:text-white">
|
||
{capabilitiesMeta?.source || 'unknown'}
|
||
</span>
|
||
</p>
|
||
<p>
|
||
HTTP methods: {supportedHTTPMethods.length > 0 ? supportedHTTPMethods.join(', ') : 'metadata unavailable'}
|
||
</p>
|
||
<p>
|
||
Missing wallet-facing methods:{' '}
|
||
{unsupportedHTTPMethods.length > 0 ? unsupportedHTTPMethods.join(', ') : 'none listed'}
|
||
</p>
|
||
<p>
|
||
Trace methods: {supportedTraceMethods.length > 0 ? supportedTraceMethods.join(', ') : 'metadata unavailable'}
|
||
</p>
|
||
{capabilities?.walletSupport?.notes?.map((note) => (
|
||
<p key={note} className="text-xs">
|
||
{note}
|
||
</p>
|
||
))}
|
||
{capabilities?.http?.notes?.map((note) => (
|
||
<p key={note} className="text-xs">
|
||
{note}
|
||
</p>
|
||
))}
|
||
{capabilitiesMeta?.lastModified ? (
|
||
<p className="text-xs">
|
||
Last modified: {formatStableTimestamp(capabilitiesMeta.lastModified)}
|
||
</p>
|
||
) : null}
|
||
</div>
|
||
</div>
|
||
|
||
<div className="rounded-lg border border-gray-200 p-4 dark:border-gray-700">
|
||
<div className="text-sm font-semibold text-gray-900 dark:text-white">Featured Chain 138 tokens</div>
|
||
<p className="mt-2 text-sm text-gray-600 dark:text-gray-400">
|
||
These tokens come from the explorer MetaMask payload and use wallet_watchAsset so the wallet gets the same
|
||
symbol, decimals, image, and optional token metadata that the explorer publishes. MetaMask requires a user
|
||
approval for each token, so the bulk actions below run as a guided sequence of wallet prompts.
|
||
</p>
|
||
<div className="mt-4 flex flex-wrap gap-2">
|
||
<button
|
||
type="button"
|
||
onClick={() => void watchTokensSequentially(featuredTokens, 'featured Chain 138')}
|
||
className="rounded bg-primary-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-primary-700"
|
||
>
|
||
Add featured tokens
|
||
</button>
|
||
<button
|
||
type="button"
|
||
onClick={() => void watchTokensSequentially(watchAssetTokens, 'Chain 138')}
|
||
className="rounded bg-gray-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-gray-700"
|
||
>
|
||
Add all Chain 138 tokens
|
||
</button>
|
||
{watchAssetProgress ? (
|
||
<span className="self-center text-sm text-gray-600 dark:text-gray-400">
|
||
Prompt {watchAssetProgress.current} of {watchAssetProgress.total}
|
||
</span>
|
||
) : null}
|
||
</div>
|
||
<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 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">Ethereum Mainnet cWUSDC</div>
|
||
<p className="mt-2 text-sm text-gray-600 dark:text-gray-400">
|
||
This refreshes the Mainnet cWUSDC custom asset metadata with the DBIS-hosted image URL. MetaMask fiat price
|
||
display still depends on MetaMask and upstream asset/price providers accepting the Mainnet listing.
|
||
</p>
|
||
<div className="mt-4 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">
|
||
{MAINNET_CWUSDC_TOKEN.symbol}{' '}
|
||
<span className="font-normal text-gray-500 dark:text-gray-400">({MAINNET_CWUSDC_TOKEN.name})</span>
|
||
</div>
|
||
<div className="mt-1 text-xs text-gray-500 dark:text-gray-400">{MAINNET_CWUSDC_TOKEN.address}</div>
|
||
<div className="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||
Ethereum Mainnet • Decimals: {MAINNET_CWUSDC_TOKEN.decimals}
|
||
</div>
|
||
</div>
|
||
<button
|
||
type="button"
|
||
onClick={() => void refreshMainnetCwusdc()}
|
||
className="rounded bg-primary-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-primary-700"
|
||
>
|
||
Refresh Mainnet cWUSDC
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{status ? <p className="text-sm text-green-600 dark:text-green-400">{status}</p> : null}
|
||
{error ? <p className="text-sm text-red-600 dark:text-red-400">{error}</p> : null}
|
||
</div>
|
||
)
|
||
}
|