Files
explorer-monorepo/frontend/src/components/wallet/MultiChainWalletImport.tsx
defiQUG b87ebee6a1
Some checks failed
Deploy Explorer Live / deploy (push) Failing after 20s
Validate Explorer / frontend (push) Failing after 24s
Validate Explorer / smoke-e2e (push) Has been skipped
feat(explorer): dual-chain wallet metadata, native coin pricing, and UI refresh.
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>
2026-06-19 16:16:17 -07:00

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