feat(explorer): token-list surfaces, homepage trim, and sprint smoke tests
Some checks failed
Deploy Explorer Live / deploy (push) Failing after 14s
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:
11
docs/TOKEN_LIST_SURFACES.md
Normal file
11
docs/TOKEN_LIST_SURFACES.md
Normal 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).
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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[]) : [])
|
||||
}
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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) &&
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
39
frontend/src/services/api/tokenListSurfaces.test.ts
Normal file
39
frontend/src/services/api/tokenListSurfaces.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
66
frontend/src/services/api/tokenListSurfaces.ts
Normal file
66
frontend/src/services/api/tokenListSurfaces.ts
Normal 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}`
|
||||
}
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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: [] }
|
||||
|
||||
28
scripts/e2e-sprint-smoke.spec.ts
Normal file
28
scripts/e2e-sprint-smoke.spec.ts
Normal 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 })
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user