Add Chain 138 wallet network metadata and stats coin-price enrichment; sync frontend explorer SPA, command center, and address/token pages with backend config. Co-authored-by: Cursor <cursoragent@cursor.com>
337 lines
12 KiB
TypeScript
337 lines
12 KiB
TypeScript
'use client'
|
|
|
|
import { useEffect, useMemo, useState } from 'react'
|
|
import { resolveExplorerApiBase } from '@/libs/frontend-api-client/api-base'
|
|
import type { TokenListToken, WalletChain } from '@/components/wallet/AddToMetaMask'
|
|
import { getActiveWalletConnectProvider } from '@/services/wallet/walletConnectClient'
|
|
import {
|
|
WALLET_FEATURED_SYMBOLS_BY_CHAIN,
|
|
WALLET_IMPORT_CHAIN_IDS,
|
|
chainLabel,
|
|
} from '@/utils/walletChainCatalog'
|
|
import { toWalletAddEthereumChainParams } from '@/utils/walletAddEthereumChain'
|
|
import {
|
|
isMobileWalletContext,
|
|
MOBILE_WALLET_BUTTON_CLASS,
|
|
resolveWalletEthereumProvider,
|
|
} from '@/utils/walletProviderEnv'
|
|
import { runWatchAssetBatch } from '@/utils/walletWatchAsset'
|
|
import { dedupeWalletWatchTokens } from '@/utils/walletWatchEligible'
|
|
|
|
type WatchAssetEntry = {
|
|
type: 'ERC20'
|
|
options: {
|
|
address: string
|
|
symbol: string
|
|
decimals: number
|
|
image?: string
|
|
}
|
|
}
|
|
|
|
type MetaMaskChainPayload = {
|
|
chainId?: number
|
|
addEthereumChain?: WalletChain
|
|
watchAssets?: WatchAssetEntry[]
|
|
}
|
|
|
|
type ChainImportState = {
|
|
chainId: number
|
|
network: WalletChain | null
|
|
tokens: TokenListToken[]
|
|
loading: boolean
|
|
error: string | null
|
|
}
|
|
|
|
type PendingMultiChainFlow = {
|
|
chainId: number
|
|
tokens: TokenListToken[]
|
|
label: string
|
|
nextIndex: number
|
|
totalAdded: number
|
|
}
|
|
|
|
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' &&
|
|
typeof candidate.symbol === 'string' &&
|
|
typeof candidate.decimals === 'number'
|
|
)
|
|
}
|
|
|
|
function watchAssetToToken(chainId: number, entry: WatchAssetEntry): TokenListToken {
|
|
return {
|
|
chainId,
|
|
address: entry.options.address,
|
|
symbol: entry.options.symbol,
|
|
name: entry.options.symbol,
|
|
decimals: entry.options.decimals,
|
|
logoURI: entry.options.image,
|
|
}
|
|
}
|
|
|
|
function getApiBase() {
|
|
return resolveExplorerApiBase({
|
|
browserOrigin: '',
|
|
serverFallback: 'https://explorer.d-bis.org',
|
|
}).replace(/\/$/, '')
|
|
}
|
|
|
|
function chainParamsForWallet(chain: WalletChain) {
|
|
return toWalletAddEthereumChainParams(chain, {
|
|
preferSingleRpc: typeof window !== 'undefined' && isMobileWalletContext(),
|
|
})
|
|
}
|
|
|
|
export default function MultiChainWalletImport() {
|
|
const [status, setStatus] = useState<string | null>(null)
|
|
const [error, setError] = useState<string | null>(null)
|
|
const [progress, setProgress] = useState<{ current: number; total: number; chainId: number } | null>(null)
|
|
const [chains, setChains] = useState<ChainImportState[]>([])
|
|
const [pendingFlow, setPendingFlow] = useState<PendingMultiChainFlow | null>(null)
|
|
const mobileWalletContext = typeof window !== 'undefined' && isMobileWalletContext()
|
|
|
|
const resolveEthereum = () => resolveWalletEthereumProvider(getActiveWalletConnectProvider())
|
|
|
|
useEffect(() => {
|
|
let active = true
|
|
const apiBase = getApiBase()
|
|
|
|
async function loadChain(chainId: number): Promise<ChainImportState> {
|
|
try {
|
|
const [networkRes, metamaskRes] = await Promise.all([
|
|
fetch(`${apiBase}/api/config/networks`, { cache: 'no-store' }),
|
|
fetch(`${apiBase}/api/v1/config/metamask?chainId=${chainId}`, { cache: 'no-store' }),
|
|
])
|
|
const networksJson = networkRes.ok ? await networkRes.json() : null
|
|
const metamaskJson = metamaskRes.ok ? await metamaskRes.json() : null
|
|
const networkList = Array.isArray(networksJson?.chains) ? networksJson.chains : []
|
|
const network =
|
|
(metamaskJson as MetaMaskChainPayload | null)?.addEthereumChain ||
|
|
networkList.find((row: WalletChain) => row.chainIdDecimal === chainId) ||
|
|
null
|
|
const watchAssets = Array.isArray((metamaskJson as MetaMaskChainPayload | null)?.watchAssets)
|
|
? ((metamaskJson as MetaMaskChainPayload).watchAssets ?? [])
|
|
: []
|
|
let tokens = watchAssets.map((entry) => watchAssetToToken(chainId, entry))
|
|
if (tokens.length === 0) {
|
|
const listRes = await fetch(`${apiBase}/api/v1/report/token-list?chainId=${chainId}`, { cache: 'no-store' })
|
|
const listJson = listRes.ok ? await listRes.json() : null
|
|
tokens = (Array.isArray(listJson?.tokens) ? listJson.tokens : []).filter(isTokenListToken)
|
|
}
|
|
return { chainId, network, tokens, loading: false, error: null }
|
|
} catch (e) {
|
|
const message = e instanceof Error ? e.message : 'Failed to load chain metadata'
|
|
return { chainId, network: null, tokens: [], loading: false, error: message }
|
|
}
|
|
}
|
|
|
|
void (async () => {
|
|
const initial = WALLET_IMPORT_CHAIN_IDS.map((chainId) => ({
|
|
chainId,
|
|
network: null,
|
|
tokens: [],
|
|
loading: true,
|
|
error: null,
|
|
}))
|
|
if (active) setChains(initial)
|
|
|
|
const loaded = await Promise.all(WALLET_IMPORT_CHAIN_IDS.map((chainId) => loadChain(chainId)))
|
|
if (active) setChains(loaded)
|
|
})()
|
|
|
|
return () => {
|
|
active = false
|
|
}
|
|
}, [])
|
|
|
|
const featuredByChain = useMemo(() => {
|
|
return new Map(
|
|
chains.map((row) => {
|
|
const featuredSymbols = new Set(WALLET_FEATURED_SYMBOLS_BY_CHAIN[row.chainId] ?? [])
|
|
const featured = row.tokens.filter((token) => featuredSymbols.has(token.symbol))
|
|
return [row.chainId, featured.length > 0 ? featured : row.tokens.slice(0, 8)]
|
|
}),
|
|
)
|
|
}, [chains])
|
|
|
|
const switchOrAddChain = async (network: WalletChain) => {
|
|
const ethereum = resolveEthereum()
|
|
if (!ethereum) {
|
|
setError('No wallet provider found. Use MetaMask mobile in-app browser or WalletConnect.')
|
|
return false
|
|
}
|
|
try {
|
|
await ethereum.request({ method: 'wallet_switchEthereumChain', params: [{ chainId: network.chainId }] })
|
|
return true
|
|
} catch (e) {
|
|
const err = e as { code?: number; message?: string }
|
|
if (err.code !== 4902) {
|
|
setError(err.message || `Failed to switch to ${network.chainName}.`)
|
|
return false
|
|
}
|
|
}
|
|
try {
|
|
await ethereum.request({ method: 'wallet_addEthereumChain', params: [chainParamsForWallet(network)] })
|
|
return true
|
|
} catch (e) {
|
|
const err = e as { message?: string }
|
|
setError(err.message || `Failed to add ${network.chainName}.`)
|
|
return false
|
|
}
|
|
}
|
|
|
|
const watchTokensSequentially = async (
|
|
chainId: number,
|
|
tokens: TokenListToken[],
|
|
label: string,
|
|
startIndex = 0,
|
|
priorAdded = 0,
|
|
) => {
|
|
setError(null)
|
|
if (startIndex === 0) {
|
|
setStatus(null)
|
|
setPendingFlow(null)
|
|
}
|
|
setProgress(null)
|
|
|
|
const ethereum = resolveEthereum()
|
|
if (!ethereum) {
|
|
setError('No wallet provider found. Use MetaMask mobile in-app browser or WalletConnect.')
|
|
return
|
|
}
|
|
|
|
const row = chains.find((entry) => entry.chainId === chainId)
|
|
if (!row?.network) {
|
|
setError(`Network metadata for ${chainLabel(chainId)} is not available yet.`)
|
|
return
|
|
}
|
|
|
|
const switched = await switchOrAddChain(row.network)
|
|
if (!switched) return
|
|
|
|
const validTokens = dedupeWalletWatchTokens(tokens.filter(isTokenListToken))
|
|
if (validTokens.length === 0) {
|
|
setError(`No token metadata is available for ${chainLabel(chainId)}.`)
|
|
return
|
|
}
|
|
|
|
const batch = await runWatchAssetBatch(ethereum, validTokens, startIndex, {
|
|
mobile: mobileWalletContext,
|
|
onProgress: (current, total) => setProgress({ current, total, chainId }),
|
|
})
|
|
|
|
const totalAdded = priorAdded + batch.addedCount
|
|
setProgress(null)
|
|
|
|
if (batch.stoppedEarly) {
|
|
setError(batch.errorMessage || 'Token import stopped.')
|
|
setPendingFlow(null)
|
|
setStatus(`${totalAdded} of ${validTokens.length} ${label} requests were accepted before the flow stopped.`)
|
|
return
|
|
}
|
|
|
|
if (batch.nextIndex < validTokens.length) {
|
|
setPendingFlow({
|
|
chainId,
|
|
tokens: validTokens,
|
|
label,
|
|
nextIndex: batch.nextIndex,
|
|
totalAdded,
|
|
})
|
|
setStatus(
|
|
`${totalAdded} of ${validTokens.length} on ${chainLabel(chainId)}. Tap Continue on that chain card for the next mobile prompts.`,
|
|
)
|
|
return
|
|
}
|
|
|
|
setPendingFlow(null)
|
|
setStatus(`${totalAdded} of ${validTokens.length} ${label} token requests were accepted on ${chainLabel(chainId)}.`)
|
|
}
|
|
|
|
const continuePendingFlow = async () => {
|
|
if (!pendingFlow) return
|
|
const { chainId, tokens, label, nextIndex, totalAdded } = pendingFlow
|
|
await watchTokensSequentially(chainId, tokens, label, nextIndex, totalAdded)
|
|
}
|
|
|
|
return (
|
|
<div className="mt-6 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">Multi-chain token import</h2>
|
|
<p className="text-sm text-gray-600 dark:text-gray-400">
|
|
Add canonical tokens on other supported networks. Each chain switches your wallet first, then runs sequential
|
|
EIP-747 <code className="rounded bg-gray-100 px-1 text-xs dark:bg-gray-900">wallet_watchAsset</code> prompts.
|
|
{mobileWalletContext ? ' On mobile, two prompts run per tap — use Continue on the chain card.' : null}
|
|
</p>
|
|
|
|
{pendingFlow ? (
|
|
<button
|
|
type="button"
|
|
onClick={() => void continuePendingFlow()}
|
|
className={`rounded bg-amber-600 px-4 py-2 text-sm font-medium text-white hover:bg-amber-700 ${MOBILE_WALLET_BUTTON_CLASS}`}
|
|
>
|
|
Continue {chainLabel(pendingFlow.chainId)} ({pendingFlow.totalAdded} added, {pendingFlow.tokens.length - pendingFlow.nextIndex} left)
|
|
</button>
|
|
) : null}
|
|
|
|
<div className="grid gap-4 xl:grid-cols-2">
|
|
{chains.map((row) => {
|
|
const featured = featuredByChain.get(row.chainId) ?? []
|
|
return (
|
|
<div key={row.chainId} className="rounded-lg border border-gray-200 p-4 dark:border-gray-700">
|
|
<div className="flex flex-col gap-2 sm:flex-row sm:items-start sm:justify-between">
|
|
<div>
|
|
<div className="text-sm font-semibold text-gray-900 dark:text-white">{chainLabel(row.chainId)}</div>
|
|
<div className="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
|
{row.loading
|
|
? 'Loading token metadata…'
|
|
: row.error
|
|
? row.error
|
|
: `${row.tokens.length} canonical tokens · ${featured.length} featured`}
|
|
</div>
|
|
</div>
|
|
<div className="flex flex-wrap gap-2">
|
|
<button
|
|
type="button"
|
|
disabled={!row.network || row.loading}
|
|
onClick={() => row.network && void switchOrAddChain(row.network).then((ok) => ok && setStatus(`Switched to ${chainLabel(row.chainId)}.`))}
|
|
className={`rounded bg-gray-600 px-3 py-2 text-xs font-medium text-white hover:bg-gray-700 disabled:opacity-50 ${MOBILE_WALLET_BUTTON_CLASS}`}
|
|
>
|
|
Switch chain
|
|
</button>
|
|
<button
|
|
type="button"
|
|
disabled={row.loading || featured.length === 0 || pendingFlow !== null}
|
|
onClick={() => void watchTokensSequentially(row.chainId, featured, `featured ${chainLabel(row.chainId)}`)}
|
|
className={`rounded bg-primary-600 px-3 py-2 text-xs font-medium text-white hover:bg-primary-700 disabled:opacity-50 ${MOBILE_WALLET_BUTTON_CLASS}`}
|
|
>
|
|
Add featured
|
|
</button>
|
|
<button
|
|
type="button"
|
|
disabled={row.loading || row.tokens.length === 0 || pendingFlow !== null}
|
|
onClick={() => void watchTokensSequentially(row.chainId, row.tokens, chainLabel(row.chainId))}
|
|
className={`rounded bg-gray-700 px-3 py-2 text-xs font-medium text-white hover:bg-gray-800 disabled:opacity-50 ${MOBILE_WALLET_BUTTON_CLASS}`}
|
|
>
|
|
Add all
|
|
</button>
|
|
</div>
|
|
</div>
|
|
{progress?.chainId === row.chainId ? (
|
|
<div className="mt-3 text-xs text-gray-600 dark:text-gray-400">
|
|
Prompt {progress.current} of {progress.total}
|
|
</div>
|
|
) : null}
|
|
</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>
|
|
)
|
|
}
|