feat(explorer): token-list surfaces, homepage trim, and sprint smoke tests
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>
This commit is contained in:
defiQUG
2026-05-22 20:22:45 -07:00
parent 0778c18e59
commit e3ec87c324
12 changed files with 446 additions and 244 deletions

View File

@@ -0,0 +1,11 @@
# Token list surfaces
The explorer uses two public token-list endpoints. Application code should pick the list through `getTokenListForSurface()` / `tokensApi.listForSurface()` rather than hard-coding `/api/config/token-list`.
| Surface | Endpoint | Use when |
|---------|----------|----------|
| `wallet` | `/api/v1/report/token-list?chainId=138` (fallback: config) | Wallet SSR, MetaMask watch list, featured-token dedup inputs |
| `catalog` | report (fallback: config) | `/tokens`, search token inference, homepage price feed curation |
| `extended` | `/api/config/token-list` | Full Metamask dual-chain catalog, provenance lookup merge |
Report list is the canonical Chain 138 trading set (31 tokens live). Config list is the extended catalog (190+ entries across chains).

View File

@@ -0,0 +1,44 @@
import Link from 'next/link'
import { Card } from '@/libs/frontend-ui-primitives'
export const CONTRACT_VERIFICATION_GUIDE_URL =
'https://gitea.d-bis.org/d-bis/proxmox/src/branch/master/docs/08-monitoring/BLOCKSCOUT_VERIFICATION_GUIDE.md'
export const FORGE_VERIFY_COMMAND =
'source scripts/lib/load-project-env.sh && ./scripts/verify/run-contract-verification-with-proxy.sh'
interface ContractVerificationCalloutProps {
address: string
verified: boolean
}
export default function ContractVerificationCallout({ address, verified }: ContractVerificationCalloutProps) {
if (verified) {
return null
}
return (
<Card title="Verify & Publish Contract" className="mb-6">
<p className="text-sm leading-6 text-gray-600 dark:text-gray-400">
This contract is not verified on the public explorer yet. Verified source improves read/write tooling,
ABI decoding, and auditability for{' '}
<span className="font-mono text-xs">{address}</span>.
</p>
<ul className="mt-4 list-disc space-y-2 pl-5 text-sm text-gray-700 dark:text-gray-300">
<li>
<strong>Forge batch (recommended):</strong>{' '}
<code className="rounded bg-gray-100 px-1.5 py-0.5 text-xs dark:bg-gray-900">{FORGE_VERIFY_COMMAND}</code>
</li>
<li>
<strong>Operator guide:</strong>{' '}
<Link href={CONTRACT_VERIFICATION_GUIDE_URL} className="text-primary-600 hover:underline" target="_blank" rel="noopener noreferrer">
Blockscout verification guide
</Link>
</li>
<li>
<strong>Native Blockscout UI (LAN):</strong> use contract verification on the VM Blockscout instance when the custom explorer UI does not expose the form.
</li>
</ul>
</Card>
)
}

View File

@@ -1,5 +1,6 @@
import { useCallback, useEffect, useState } from 'react'
import { Card } from '@/libs/frontend-ui-primitives/Card'
import { Address } from '@/libs/frontend-ui-primitives'
import Link from 'next/link'
import { blocksApi, type Block } from '@/services/api/blocks'
import {
@@ -129,6 +130,7 @@ export default function Home({
const [featuredPrices, setFeaturedPrices] = useState<TokenAggregationTokenSnapshot[]>([])
const [missionExpanded, setMissionExpanded] = useState(false)
const [relayExpanded, setRelayExpanded] = useState(false)
const [statsDetailsExpanded, setStatsDetailsExpanded] = useState(false)
const [relayPage, setRelayPage] = useState(1)
const [relayFeedState, setRelayFeedState] = useState<'connecting' | 'live' | 'fallback'>(
initialRelaySummary || initialBridgeStatus ? 'fallback' : 'connecting'
@@ -163,13 +165,13 @@ export default function Home({
}, [chainId])
const loadFeaturedPrices = useCallback(async () => {
const [curatedResult, reportResult] = await Promise.all([
tokensApi.listCuratedSafe(chainId),
const [catalogResult, reportResult] = await Promise.all([
tokensApi.listForSurface('catalog', chainId),
tokensApi.listReportSafe(chainId),
])
const addresses = resolveHomePriceFeedAddresses(
curatedResult.ok ? curatedResult.data : [],
catalogResult.ok ? catalogResult.data : [],
reportResult.ok ? reportResult.data : [],
)
@@ -751,9 +753,20 @@ export default function Home({
</Card>
)}
{(relaySummary || bridgeStatus || stats) && (
<div className="mb-4 flex flex-wrap items-center gap-2 rounded-xl border border-gray-200 bg-white/80 px-4 py-3 text-sm shadow-sm dark:border-gray-800 dark:bg-gray-950/60">
<EntityBadge label={`block ${latestBlock != null ? latestBlock.toLocaleString() : 'unknown'}`} tone="info" />
{chainStatus?.status ? <EntityBadge label={`chain ${chainStatus.status}`} tone={chainStatus.status === 'operational' ? 'success' : 'warning'} /> : null}
{relaySummary ? <EntityBadge label={`${relayOperationalCount} relays ok`} tone="success" /> : null}
<EntityBadge label={relayFeedState === 'live' ? 'live feed' : relayFeedState === 'fallback' ? 'snapshot feed' : 'connecting'} tone={relayFeedState === 'live' ? 'success' : 'info'} />
<span className="text-gray-600 dark:text-gray-400">{latestTransactionAgeLabel}</span>
</div>
)}
{stats && (
<div className="mb-8 space-y-4">
<div className="grid grid-cols-1 gap-3 sm:gap-4 md:grid-cols-4">
<div className="mb-6 space-y-4">
<p className="text-sm font-semibold text-gray-900 dark:text-white">Network overview</p>
<div className="grid grid-cols-2 gap-3 md:grid-cols-4">
{primaryMetricCards.map((card) => (
<Card key={card.label}>
<div className="text-sm text-gray-600 dark:text-gray-400">{card.label}</div>
@@ -763,48 +776,77 @@ export default function Home({
))}
</div>
<div className="grid grid-cols-1 gap-3 sm:gap-4 md:grid-cols-3">
{activityMetricCards.map((card) => (
<Card key={card.label}>
<div className="text-sm text-gray-600 dark:text-gray-400">{card.label}</div>
<div className="text-xl font-bold sm:text-2xl">{card.value}</div>
<div className="mt-2 text-sm text-gray-700 dark:text-gray-300">{card.note}</div>
<div className="mt-2 text-xs text-gray-500 dark:text-gray-400">{card.detail}</div>
</Card>
))}
</div>
<button
type="button"
onClick={() => setStatsDetailsExpanded((current) => !current)}
className="text-sm font-semibold text-primary-600 hover:underline"
>
{statsDetailsExpanded ? 'Hide telemetry and freshness' : 'Show telemetry and freshness'}
</button>
{mode === 'guided' ? (
<div className="grid grid-cols-1 gap-3 sm:gap-4 md:grid-cols-4">
{secondaryMetricCards.map((card) => (
<Card key={card.label}>
<div className="text-sm text-gray-600 dark:text-gray-400">{card.label}</div>
<div className="text-xl font-bold sm:text-2xl">{card.value}</div>
<div className="mt-2 text-xs text-gray-500 dark:text-gray-400">{card.note}</div>
</Card>
))}
</div>
) : (
<Card>
<div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
<div>
<div className="text-sm font-semibold text-gray-900 dark:text-white">Telemetry Snapshot</div>
<div className="mt-1 text-xs text-gray-500 dark:text-gray-400">
Secondary public stats in a denser expert layout.
</div>
</div>
<div className="grid flex-1 gap-3 sm:grid-cols-2 xl:grid-cols-4">
{statsDetailsExpanded ? (
<>
<div className="grid grid-cols-1 gap-3 sm:gap-4 md:grid-cols-3">
{activityMetricCards.map((card) => (
<Card key={card.label}>
<div className="text-sm text-gray-600 dark:text-gray-400">{card.label}</div>
<div className="text-xl font-bold sm:text-2xl">{card.value}</div>
<div className="mt-2 text-sm text-gray-700 dark:text-gray-300">{card.note}</div>
<div className="mt-2 text-xs text-gray-500 dark:text-gray-400">{card.detail}</div>
</Card>
))}
</div>
{mode === 'guided' ? (
<div className="grid grid-cols-1 gap-3 sm:gap-4 md:grid-cols-4">
{secondaryMetricCards.map((card) => (
<div key={card.label} className="rounded-2xl border border-gray-200 bg-gray-50/70 px-4 py-3 dark:border-gray-800 dark:bg-gray-900/40">
<div className="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">{card.label}</div>
<div className="mt-1 text-lg font-semibold text-gray-900 dark:text-white">{card.value}</div>
<div className="mt-1 text-xs text-gray-500 dark:text-gray-400">{card.note}</div>
</div>
<Card key={card.label}>
<div className="text-sm text-gray-600 dark:text-gray-400">{card.label}</div>
<div className="text-xl font-bold sm:text-2xl">{card.value}</div>
<div className="mt-2 text-xs text-gray-500 dark:text-gray-400">{card.note}</div>
</Card>
))}
</div>
</div>
</Card>
)}
) : (
<Card>
<div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
<div>
<div className="text-sm font-semibold text-gray-900 dark:text-white">Telemetry Snapshot</div>
<div className="mt-1 text-xs text-gray-500 dark:text-gray-400">
Secondary public stats in a denser expert layout.
</div>
</div>
<div className="grid flex-1 gap-3 sm:grid-cols-2 xl:grid-cols-4">
{secondaryMetricCards.map((card) => (
<div key={card.label} className="rounded-2xl border border-gray-200 bg-gray-50/70 px-4 py-3 dark:border-gray-800 dark:bg-gray-900/40">
<div className="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">{card.label}</div>
<div className="mt-1 text-lg font-semibold text-gray-900 dark:text-white">{card.value}</div>
<div className="mt-1 text-xs text-gray-500 dark:text-gray-400">{card.note}</div>
</div>
))}
</div>
</div>
</Card>
)}
<ActivityContextPanel
context={activityContext}
title="Freshness Interpretation"
compact
/>
<FreshnessTrustNote
className="mt-3"
context={activityContext}
stats={stats}
bridgeStatus={bridgeStatus}
scopeLabel={
mode === 'guided'
? 'Homepage status combines chain freshness, transaction visibility, and mission-control posture.'
: 'Homepage freshness view aligns chain, transaction, and mission-control posture.'
}
/>
</>
) : null}
</div>
)}
@@ -838,25 +880,6 @@ export default function Home({
</div>
) : null}
<div className="mb-8">
<ActivityContextPanel
context={activityContext}
title="Freshness Interpretation"
compact
/>
<FreshnessTrustNote
className="mt-3"
context={activityContext}
stats={stats}
bridgeStatus={bridgeStatus}
scopeLabel={
mode === 'guided'
? 'Homepage status combines chain freshness, transaction visibility, and mission-control posture.'
: 'Homepage freshness view aligns chain, transaction, and mission-control posture.'
}
/>
</div>
{!stats && (
<Card className="mb-8">
<p className="text-sm text-gray-600 dark:text-gray-400">
@@ -865,7 +888,46 @@ export default function Home({
</Card>
)}
<Card title="Recent Blocks">
<div className="mb-8 grid grid-cols-1 gap-4 lg:grid-cols-2">
<Card title="Recent Transactions">
{recentTransactions.length === 0 ? (
<p className="text-sm text-gray-600 dark:text-gray-400">
Recent transactions are unavailable right now.
</p>
) : (
<div className="space-y-2">
{recentTransactions.map((transaction) => (
<div key={transaction.hash} className="flex flex-col gap-1.5 border-b border-gray-200 py-2 last:border-0 dark:border-gray-700 sm:flex-row sm:items-center sm:justify-between">
<div>
<Link href={`/transactions/${transaction.hash}`} className="text-primary-600 hover:underline">
{transaction.hash.slice(0, 10)}...{transaction.hash.slice(-8)}
</Link>
<div className="mt-1 text-xs text-gray-500 dark:text-gray-400">
Block #{transaction.block_number} · from{' '}
<Address address={transaction.from_address} truncate showCopy={false} />
{transaction.to_address ? (
<>
{' '} <Address address={transaction.to_address} truncate showCopy={false} />
</>
) : null}
</div>
</div>
<div className="text-sm text-gray-600 dark:text-gray-400 sm:text-right">
<div>{formatWeiAsEth(transaction.value, 4)}</div>
<div className="text-xs">{formatTimestamp(transaction.created_at)}</div>
</div>
</div>
))}
</div>
)}
<div className="mt-4">
<Link href="/transactions" className="text-primary-600 hover:underline">
View all transactions
</Link>
</div>
</Card>
<Card title="Recent Blocks">
{recentBlocks.length === 0 ? (
<p className="text-sm text-gray-600 dark:text-gray-400">
Recent blocks are unavailable right now.
@@ -903,120 +965,35 @@ export default function Home({
View all blocks
</Link>
</div>
</Card>
<div className="mt-8 grid grid-cols-1 gap-4 lg:grid-cols-2">
<Card title="Activity Pulse">
<p className="text-sm leading-6 text-gray-600 dark:text-gray-400">
{mode === 'guided'
? 'A concise public view of chain activity, index coverage, and recent execution patterns.'
: 'Public chain activity and index posture.'}
</p>
<div className="mt-4 grid gap-3 sm:grid-cols-2">
<div className="rounded-2xl border border-gray-200 bg-gray-50 p-4 dark:border-gray-700 dark:bg-gray-900/40">
<div className="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">Latest Daily Volume</div>
<div className="mt-2 text-xl font-semibold text-gray-900 dark:text-white">
{latestTrendPoint ? latestTrendPoint.transaction_count.toLocaleString() : 'Unknown'}
</div>
<div className="mt-1 text-sm text-gray-600 dark:text-gray-400">{latestTrendPoint?.date || 'Trend feed unavailable'}</div>
</div>
<div className="rounded-2xl border border-gray-200 bg-gray-50 p-4 dark:border-gray-700 dark:bg-gray-900/40">
<div className="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">Recent Success Rate</div>
<div className="mt-2 text-xl font-semibold text-gray-900 dark:text-white">
{activitySnapshot ? `${Math.round(activitySnapshot.success_rate * 100)}%` : 'Unknown'}
</div>
<div className="mt-1 text-sm text-gray-600 dark:text-gray-400">
{activitySnapshot ? `${activitySnapshot.sample_size} sampled transactions` : 'Recent activity snapshot unavailable'}
</div>
</div>
<div className="rounded-2xl border border-gray-200 bg-gray-50 p-4 dark:border-gray-700 dark:bg-gray-900/40">
<div className="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">Avg Recent Fee</div>
<div className="mt-2 text-xl font-semibold text-gray-900 dark:text-white">
{activitySnapshot ? formatWeiAsEth(Math.round(activitySnapshot.average_fee_wei).toString(), 6) : 'Unknown'}
</div>
<div className="mt-1 text-sm text-gray-600 dark:text-gray-400">Average fee from the recent public sample.</div>
</div>
<div className="rounded-2xl border border-gray-200 bg-gray-50 p-4 dark:border-gray-700 dark:bg-gray-900/40">
<div className="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">Peak Charted Day</div>
<div className="mt-2 text-xl font-semibold text-gray-900 dark:text-white">
{peakTrendPoint ? peakTrendPoint.transaction_count.toLocaleString() : 'Unknown'}
</div>
<div className="mt-1 text-sm text-gray-600 dark:text-gray-400">{peakTrendPoint?.date || 'No trend data yet'}</div>
</div>
</div>
<div className="mt-4">
<Link href="/analytics" className="text-primary-600 hover:underline">
Open full analytics
</Link>
</div>
</Card>
<Card title="Explorer Shortcuts">
<p className="text-sm leading-6 text-gray-600 dark:text-gray-400">
Go directly to the explorer surfaces that provide the strongest operational and discovery context.
</p>
<div className="mt-4 flex flex-wrap gap-3 text-sm">
<Link href="/search" className="text-primary-600 hover:underline">
Search
</Link>
<Link href="/transactions" className="text-primary-600 hover:underline">
Transactions
</Link>
<Link href="/tokens" className="text-primary-600 hover:underline">
Tokens
</Link>
<Link href="/addresses" className="text-primary-600 hover:underline">
Addresses
</Link>
<Link href="/analytics" className="text-primary-600 hover:underline">
Analytics
</Link>
</div>
</Card>
<Card title="Liquidity & Routes">
<p className="text-sm leading-6 text-gray-600 dark:text-gray-400">
Explore the public Chain 138 DODO PMM liquidity mesh, the canonical route matrix, and the
partner payload endpoints exposed through the explorer.
</p>
<div className="mt-4">
<Link href="/routes" className="text-primary-600 hover:underline">
Open routes and liquidity
</Link>
</div>
</Card>
<Card title="Wallet & Token Discovery">
<p className="text-sm leading-6 text-gray-600 dark:text-gray-400">
Add Chain 138, Ethereum Mainnet, and ALL Mainnet to MetaMask, then use the explorer token
list URL so supported tokens appear automatically.
</p>
<div className="mt-4">
<Link href="/wallet" className="text-primary-600 hover:underline">
Open wallet tools
</Link>
</div>
</Card>
<Card title="Bridge & Relay Monitoring">
<p className="text-sm leading-6 text-gray-600 dark:text-gray-400">
Open the public bridge monitoring surface for relay status, mission-control links, bridge trace tooling,
and the visual command center entry points.
</p>
<div className="mt-4">
<Link href="/bridge" className="text-primary-600 hover:underline">
Open bridge monitoring
</Link>
</div>
</Card>
<Card title="Operations Hub">
<p className="text-sm leading-6 text-gray-600 dark:text-gray-400">
Open the public operations surface for wrapped-asset references, analytics shortcuts, operator links,
system topology views, and other Chain 138 support tools.
</p>
<div className="mt-4">
<Link href="/operations" className="text-primary-600 hover:underline">
Open operations hub
</Link>
</div>
</Card>
</div>
<Card title="Quick links" className="mt-8">
<p className="text-sm leading-6 text-gray-600 dark:text-gray-400">
Jump to the explorer surfaces used most often for discovery, liquidity, wallet setup, and bridge monitoring.
</p>
<div className="mt-4 grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
<Link href="/search" className="rounded-xl border border-gray-200 px-4 py-3 text-sm font-semibold text-primary-600 hover:border-primary-400 dark:border-gray-800">
Search
</Link>
<Link href="/tokens" className="rounded-xl border border-gray-200 px-4 py-3 text-sm font-semibold text-primary-600 hover:border-primary-400 dark:border-gray-800">
Tokens
</Link>
<Link href="/wallet" className="rounded-xl border border-gray-200 px-4 py-3 text-sm font-semibold text-primary-600 hover:border-primary-400 dark:border-gray-800">
Wallet & MetaMask
</Link>
<Link href="/routes" className="rounded-xl border border-gray-200 px-4 py-3 text-sm font-semibold text-primary-600 hover:border-primary-400 dark:border-gray-800">
Liquidity & routes
</Link>
<Link href="/bridge" className="rounded-xl border border-gray-200 px-4 py-3 text-sm font-semibold text-primary-600 hover:border-primary-400 dark:border-gray-800">
Bridge monitoring
</Link>
<Link href="/analytics" className="rounded-xl border border-gray-200 px-4 py-3 text-sm font-semibold text-primary-600 hover:border-primary-400 dark:border-gray-800">
Analytics
</Link>
</div>
</Card>
</main>
)
}

View File

@@ -460,7 +460,7 @@ export function AddToMetaMask({
useEffect(() => {
let active = true
tokensApi.listCuratedSafe(138).then(({ ok, data }) => {
tokensApi.listForSurface('wallet', 138).then(({ ok, data }) => {
if (active) {
setCuratedTokens(ok ? (data as TokenListToken[]) : [])
}

View File

@@ -32,6 +32,7 @@ import PaginationControls from '@/components/common/PaginationControls'
import SectionTabs, { type SectionTab } from '@/components/common/SectionTabs'
import GruStandardsCard from '@/components/common/GruStandardsCard'
import ContractCodeWorkspace from '@/components/explorer/ContractCodeWorkspace'
import ContractVerificationCallout from '@/components/explorer/ContractVerificationCallout'
import { getGruStandardsProfileSafe, type GruStandardsProfile } from '@/services/api/gru'
import { getGruExplorerMetadata } from '@/services/api/gruExplorerData'
import { tokenAggregationApi, type TokenAggregationTokenSnapshot } from '@/services/api/tokenAggregation'
@@ -668,6 +669,10 @@ export default function AddressDetailPage() {
</dl>
</Card>
{addressInfo.is_contract ? (
<ContractVerificationCallout address={addressInfo.address} verified={Boolean(addressInfo.is_verified)} />
) : null}
<SectionTabs tabs={tabs} activeTab={activeTab} onChange={setActiveTab} className="mb-6" />
{activeTab === 'contract' && addressInfo.is_contract && (

View File

@@ -3,7 +3,8 @@ import { useEffect, useMemo, useState } from 'react'
import { useRouter } from 'next/router'
import { Card, Address } from '@/libs/frontend-ui-primitives'
import Link from 'next/link'
import { configApi, type TokenListToken } from '@/services/api/config'
import type { TokenListToken } from '@/services/api/config'
import { tokensApi } from '@/services/api/tokens'
import { tokenAggregationApi, type TokenAggregationTokenSnapshot } from '@/services/api/tokenAggregation'
import EntityBadge from '@/components/common/EntityBadge'
import {
@@ -15,6 +16,7 @@ import {
} from '@/utils/search'
import PageIntro from '@/components/common/PageIntro'
import { fetchPublicJson } from '@/utils/publicExplorer'
import { fetchTokenListForSurface } from '@/services/api/tokenListSurfaces'
import { useUiMode } from '@/components/common/UiModeContext'
import MarketEvidenceNote from '@/components/common/MarketEvidenceNote'
@@ -92,9 +94,9 @@ export default function SearchPage({
}
let active = true
configApi.getTokenList().then((response) => {
tokensApi.listForSurface('catalog', 138).then(({ ok, data }) => {
if (active) {
setCuratedTokens((response.tokens || []).filter((token) => token.chainId === 138))
setCuratedTokens(ok ? data : [])
}
}).catch(() => {
if (active) {
@@ -491,10 +493,7 @@ export default function SearchPage({
export const getServerSideProps: GetServerSideProps<SearchPageProps> = async (context) => {
const initialQuery = typeof context.query.q === 'string' ? context.query.q.trim() : ''
const tokenListResult = await fetchPublicJson<{ tokens?: TokenListToken[] }>('/api/config/token-list').catch(() => null)
const initialCuratedTokens = Array.isArray(tokenListResult?.tokens)
? tokenListResult.tokens.filter((token) => token.chainId === 138)
: []
const { tokens: initialCuratedTokens } = await fetchTokenListForSurface('catalog', 138)
const shouldFetchSearch =
Boolean(initialQuery) &&

View File

@@ -9,7 +9,7 @@ import MarketEvidenceNote from '@/components/common/MarketEvidenceNote'
import { tokensApi } from '@/services/api/tokens'
import type { TokenListToken } from '@/services/api/config'
import { tokenAggregationApi, type TokenAggregationTokenSnapshot } from '@/services/api/tokenAggregation'
import { fetchPublicJson } from '@/utils/publicExplorer'
import { fetchTokenListForSurface, TOKEN_LIST_SURFACE_LABELS } from '@/services/api/tokenListSurfaces'
import { selectCuratedFeaturedTokens } from '@/utils/featuredTokens'
const quickSearches = [
@@ -71,7 +71,7 @@ export default function TokensPage({ initialCuratedTokens }: TokensPageProps) {
}
let active = true
tokensApi.listCuratedSafe(138).then(({ ok, data }) => {
tokensApi.listForSurface('catalog', 138).then(({ ok, data }) => {
if (active) {
setCuratedTokens(ok ? data : [])
}
@@ -117,7 +117,7 @@ export default function TokensPage({ initialCuratedTokens }: TokensPageProps) {
<PageIntro
eyebrow="Token Discovery"
title="Tokens"
description="Browse curated Chain 138 assets, open token contracts directly, and review holders, transfers, liquidity, and provenance from the same institutional explorer surface."
description="Browse the canonical Chain 138 trading set, open token contracts directly, and review holders, transfers, liquidity, and provenance from the same institutional explorer surface."
actions={[
{ href: '/wallet', label: 'Wallet tools' },
{ href: '/liquidity', label: 'Liquidity access' },
@@ -146,6 +146,9 @@ export default function TokensPage({ initialCuratedTokens }: TokensPageProps) {
<div className="mt-5">
<Card title="Curated Chain 138 tokens">
<p className="mb-4 text-sm text-gray-600 dark:text-gray-400">
{TOKEN_LIST_SURFACE_LABELS.catalog}. Showing {featuredCuratedTokens.length} featured tokens from the live report list.
</p>
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-3">
{featuredCuratedTokens
.filter((token): token is TokenListToken & { address: string } => typeof token.address === 'string' && token.address.trim().length > 0)
@@ -215,15 +218,11 @@ export default function TokensPage({ initialCuratedTokens }: TokensPageProps) {
}
export const getStaticProps: GetStaticProps<TokensPageProps> = async () => {
const tokenListResult = await fetchPublicJson<{ tokens?: TokenListToken[] }>('/api/config/token-list').catch(() => null)
const { tokens } = await fetchTokenListForSurface('catalog', 138)
return {
props: {
initialCuratedTokens: Array.isArray(tokenListResult?.tokens)
? tokenListResult.tokens
.filter((token) => token.chainId === 138 && typeof token.address === 'string' && token.address.trim().length > 0)
.sort((left, right) => (left.symbol || left.name || '').localeCompare(right.symbol || right.name || ''))
: [],
initialCuratedTokens: tokens,
},
revalidate: 300,
}

View File

@@ -0,0 +1,39 @@
import { describe, expect, it } from 'vitest'
import {
filterChain138Tokens,
mergeTokenListLookups,
TOKEN_LIST_SURFACE_LABELS,
} from './tokenListSurfaces'
import type { TokenListToken } from './config'
describe('tokenListSurfaces', () => {
it('filters and sorts chain 138 tokens', () => {
const tokens: TokenListToken[] = [
{ chainId: 138, address: '0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb', symbol: 'cUSDC' },
{ chainId: 1, address: '0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', symbol: 'USDC' },
{ chainId: 138, address: '0xcccccccccccccccccccccccccccccccccccccccc', symbol: 'cUSDT' },
]
expect(filterChain138Tokens(tokens).map((token) => token.symbol)).toEqual(['cUSDC', 'cUSDT'])
})
it('prefers primary tokens when merging lookup maps', () => {
const primary: TokenListToken[] = [
{ chainId: 138, address: '0x1111111111111111111111111111111111111111', symbol: 'cUSDT', tags: ['canonical'] },
]
const secondary: TokenListToken[] = [
{ chainId: 138, address: '0x1111111111111111111111111111111111111111', symbol: 'cUSDT', tags: ['staged'] },
{ chainId: 138, address: '0x2222222222222222222222222222222222222222', symbol: 'cUSDC' },
]
const lookup = mergeTokenListLookups(primary, secondary)
expect(lookup.get('0x1111111111111111111111111111111111111111')?.tags).toEqual(['canonical'])
expect(lookup.get('0x2222222222222222222222222222222222222222')?.symbol).toBe('cUSDC')
})
it('documents surface labels', () => {
expect(TOKEN_LIST_SURFACE_LABELS.wallet).toMatch(/report/i)
expect(TOKEN_LIST_SURFACE_LABELS.extended).toMatch(/catalog/i)
})
})

View File

@@ -0,0 +1,66 @@
import type { TokenListToken } from './config'
import { fetchPublicJson } from '@/utils/publicExplorer'
export type TokenListSurface = 'wallet' | 'catalog' | 'extended'
export const TOKEN_LIST_SURFACE_LABELS: Record<TokenListSurface, string> = {
wallet: 'Canonical Chain 138 report list (wallet and MetaMask)',
catalog: 'Canonical Chain 138 trading set',
extended: 'Extended Metamask dual-chain catalog',
}
const REPORT_TOKEN_LIST_PATH = '/api/v1/report/token-list?chainId=138'
const CONFIG_TOKEN_LIST_PATH = '/api/config/token-list'
export function filterChain138Tokens(tokens: TokenListToken[], chainId = 138): TokenListToken[] {
return tokens
.filter((token) => token.chainId === chainId && typeof token.address === 'string' && token.address.trim().length > 0)
.sort((left, right) => (left.symbol || left.name || '').localeCompare(right.symbol || right.name || ''))
}
export function mergeTokenListLookups(
primary: TokenListToken[],
secondary: TokenListToken[],
): Map<string, TokenListToken> {
const lookup = new Map<string, TokenListToken>()
for (const token of secondary) {
if (token.address) {
lookup.set(token.address.toLowerCase(), token)
}
}
for (const token of primary) {
if (token.address) {
lookup.set(token.address.toLowerCase(), token)
}
}
return lookup
}
export async function fetchTokenListForSurface(
surface: TokenListSurface,
chainId = 138,
): Promise<{ tokens: TokenListToken[]; source: 'report' | 'config' }> {
if (surface === 'extended') {
const configResult = await fetchPublicJson<{ tokens?: TokenListToken[] }>(CONFIG_TOKEN_LIST_PATH).catch(() => null)
return {
tokens: filterChain138Tokens(Array.isArray(configResult?.tokens) ? configResult.tokens : [], chainId),
source: 'config',
}
}
const reportResult = await fetchPublicJson<{ tokens?: TokenListToken[] }>(REPORT_TOKEN_LIST_PATH).catch(() => null)
const reportTokens = filterChain138Tokens(Array.isArray(reportResult?.tokens) ? reportResult.tokens : [], chainId)
if (reportTokens.length > 0) {
return { tokens: reportTokens, source: 'report' }
}
const configResult = await fetchPublicJson<{ tokens?: TokenListToken[] }>(CONFIG_TOKEN_LIST_PATH).catch(() => null)
return {
tokens: filterChain138Tokens(Array.isArray(configResult?.tokens) ? configResult.tokens : [], chainId),
source: 'config',
}
}
export function resolveTokenListClientPath(surface: TokenListSurface, chainId = 138): string {
return surface === 'extended' ? CONFIG_TOKEN_LIST_PATH : `/api/v1/report/token-list?chainId=${chainId}`
}

View File

@@ -88,51 +88,59 @@ describe('tokensApi', () => {
expect(transfers.data[0].token_symbol).toBe('cUSDT')
})
it('builds provenance and curated token lists from the token list config', async () => {
const fetchMock = vi.fn()
.mockResolvedValueOnce({
ok: true,
json: async () => ({
tokens: [
{
chainId: 138,
address: '0xlisted',
symbol: 'cUSDT',
name: 'Tether USD (Compliant)',
tags: ['compliant', 'bridge'],
},
{
chainId: 1,
address: '0xother',
symbol: 'OTHER',
},
],
}),
})
.mockResolvedValueOnce({
ok: true,
json: async () => ({
tokens: [
{
chainId: 138,
address: '0xlisted',
symbol: 'cUSDT',
name: 'Tether USD (Compliant)',
tags: ['compliant', 'bridge'],
},
],
}),
})
it('builds provenance and curated token lists from report and config surfaces', async () => {
const configPayload = {
tokens: [
{
chainId: 138,
address: '0xlisted',
symbol: 'cUSDT',
name: 'Tether USD (Compliant)',
tags: ['compliant', 'bridge'],
},
{
chainId: 1,
address: '0xother',
symbol: 'OTHER',
},
],
}
const reportPayload = {
tokens: [
{
chainId: 138,
address: '0xlisted',
symbol: 'cUSDT',
name: 'Tether USD (Compliant)',
tags: ['compliant', 'bridge'],
},
],
}
vi.stubGlobal('fetch', fetchMock)
vi.stubGlobal(
'fetch',
vi.fn(async (input: RequestInfo) => {
const url = String(input)
if (url.includes('/api/config/token-list')) {
return { ok: true, json: async () => configPayload }
}
if (url.includes('/api/v1/report/token-list')) {
return { ok: true, json: async () => reportPayload }
}
throw new Error(`unexpected fetch: ${url}`)
}),
)
const provenance = await tokensApi.getProvenanceSafe('0xlisted')
const curated = await tokensApi.listCuratedSafe(138)
const catalog = await tokensApi.listForSurface('catalog', 138)
expect(provenance.ok).toBe(true)
expect(provenance.data?.listed).toBe(true)
expect(provenance.data?.tags).toEqual(['compliant', 'bridge'])
expect(curated.data).toHaveLength(1)
expect(curated.data[0].symbol).toBe('cUSDT')
expect(catalog.data).toHaveLength(1)
expect(catalog.source).toBe('report')
})
})

View File

@@ -2,6 +2,11 @@ import { fetchBlockscoutJson, normalizeAddressTokenTransfer, type BlockscoutToke
import { configApi, type TokenListToken } from './config'
import { routesApi, type MissionControlLiquidityPool } from './routes'
import { tokenAggregationApi } from './tokenAggregation'
import {
filterChain138Tokens,
mergeTokenListLookups,
type TokenListSurface,
} from './tokenListSurfaces'
import type { AddressTokenTransfer } from './addresses'
export interface TokenProfile {
@@ -147,14 +152,14 @@ function normalizeTokenHolder(raw: {
}
async function getTokenListLookup(): Promise<Map<string, TokenListToken>> {
const response = await configApi.getTokenList()
const lookup = new Map<string, TokenListToken>()
for (const token of response.tokens || []) {
if (token.address) {
lookup.set(token.address.toLowerCase(), token)
}
}
return lookup
const [reportResult, extendedResult] = await Promise.all([
tokensApi.listReportSafe(138),
tokensApi.listForSurface('extended', 138),
])
return mergeTokenListLookups(
reportResult.ok ? reportResult.data : [],
extendedResult.ok ? extendedResult.data : [],
)
}
export const tokensApi = {
@@ -258,26 +263,47 @@ export const tokensApi = {
}
},
listCuratedSafe: async (chainId = 138): Promise<{ ok: boolean; data: TokenListToken[] }> => {
listForSurface: async (
surface: TokenListSurface,
chainId = 138,
): Promise<{ ok: boolean; data: TokenListToken[]; source?: 'report' | 'config' }> => {
try {
if (surface === 'extended') {
const response = await configApi.getTokenList()
return {
ok: true,
source: 'config',
data: filterChain138Tokens(response.tokens || [], chainId),
}
}
const report = await tokensApi.listReportSafe(chainId)
if (report.ok && report.data.length > 0) {
return { ok: true, source: 'report', data: report.data }
}
const response = await configApi.getTokenList()
const data = (response.tokens || [])
.filter((token) => token.chainId === chainId && typeof token.address === 'string' && token.address.trim().length > 0)
.sort((left, right) => (left.symbol || left.name || '').localeCompare(right.symbol || left.name || ''))
return { ok: true, data }
return {
ok: true,
source: 'config',
data: filterChain138Tokens(response.tokens || [], chainId),
}
} catch {
return { ok: false, data: [] }
}
},
listCuratedSafe: async (chainId = 138): Promise<{ ok: boolean; data: TokenListToken[] }> => {
const result = await tokensApi.listForSurface('extended', chainId)
return { ok: result.ok, data: result.data }
},
listReportSafe: async (chainId = 138): Promise<{ ok: boolean; data: TokenListToken[] }> => {
try {
const response = await fetchBlockscoutJson<{ tokens?: TokenListToken[] }>(
`/api/v1/report/token-list?chainId=${chainId}`,
)
const data = (response.tokens || [])
.filter((token) => token.chainId === chainId && typeof token.address === 'string' && token.address.trim().length > 0)
.sort((left, right) => (left.symbol || left.name || '').localeCompare(right.symbol || left.name || ''))
const data = filterChain138Tokens(response.tokens || [], chainId)
return { ok: true, data }
} catch {
return { ok: false, data: [] }

View File

@@ -0,0 +1,28 @@
import { expect, test } from '@playwright/test'
const EXPLORER_URL = process.env.EXPLORER_URL || 'https://explorer.d-bis.org'
const CANONICAL_CUSDT = '0x93E66202A11B1772E55407B32B44e5Cd8eda7f22'
test.describe('Explorer sprint smoke', () => {
test('homepage dashboard loads', async ({ page }) => {
await page.goto(`${EXPLORER_URL}/`, { waitUntil: 'domcontentloaded', timeout: 20000 })
await expect(page.getByText(/Network overview/i)).toBeVisible({ timeout: 10000 })
await expect(page.getByRole('heading', { name: /Recent Transactions/i })).toBeVisible({ timeout: 10000 })
})
test('wallet page loads', async ({ page }) => {
await page.goto(`${EXPLORER_URL}/wallet`, { waitUntil: 'domcontentloaded', timeout: 20000 })
await expect(page.getByRole('heading', { name: /Wallet Tools/i })).toBeVisible({ timeout: 10000 })
})
test('tokens page loads', async ({ page }) => {
await page.goto(`${EXPLORER_URL}/tokens`, { waitUntil: 'domcontentloaded', timeout: 20000 })
await expect(page.getByRole('heading', { name: /^Tokens$/i })).toBeVisible({ timeout: 10000 })
await expect(page.getByText(/Canonical Chain 138 trading set/i).first()).toBeVisible({ timeout: 10000 })
})
test('canonical cUSDT token detail loads', async ({ page }) => {
await page.goto(`${EXPLORER_URL}/tokens/${CANONICAL_CUSDT}`, { waitUntil: 'domcontentloaded', timeout: 20000 })
await expect(page.getByText(/cUSDT|Tether/i).first()).toBeVisible({ timeout: 10000 })
})
})