From e3ec87c324e3b4bc0f0e0336661666eec47a0515 Mon Sep 17 00:00:00 2001 From: defiQUG Date: Fri, 22 May 2026 20:22:45 -0700 Subject: [PATCH] feat(explorer): token-list surfaces, homepage trim, and sprint smoke tests 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 --- docs/TOKEN_LIST_SURFACES.md | 11 + .../explorer/ContractVerificationCallout.tsx | 44 +++ frontend/src/components/home/HomePage.tsx | 327 ++++++++---------- .../src/components/wallet/AddToMetaMask.tsx | 2 +- frontend/src/pages/addresses/[address].tsx | 5 + frontend/src/pages/search/index.tsx | 13 +- frontend/src/pages/tokens/index.tsx | 17 +- .../services/api/tokenListSurfaces.test.ts | 39 +++ .../src/services/api/tokenListSurfaces.ts | 66 ++++ frontend/src/services/api/tokens.test.ts | 80 +++-- frontend/src/services/api/tokens.ts | 58 +++- scripts/e2e-sprint-smoke.spec.ts | 28 ++ 12 files changed, 446 insertions(+), 244 deletions(-) create mode 100644 docs/TOKEN_LIST_SURFACES.md create mode 100644 frontend/src/components/explorer/ContractVerificationCallout.tsx create mode 100644 frontend/src/services/api/tokenListSurfaces.test.ts create mode 100644 frontend/src/services/api/tokenListSurfaces.ts create mode 100644 scripts/e2e-sprint-smoke.spec.ts diff --git a/docs/TOKEN_LIST_SURFACES.md b/docs/TOKEN_LIST_SURFACES.md new file mode 100644 index 0000000..4098c44 --- /dev/null +++ b/docs/TOKEN_LIST_SURFACES.md @@ -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). diff --git a/frontend/src/components/explorer/ContractVerificationCallout.tsx b/frontend/src/components/explorer/ContractVerificationCallout.tsx new file mode 100644 index 0000000..80174fc --- /dev/null +++ b/frontend/src/components/explorer/ContractVerificationCallout.tsx @@ -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 ( + +

+ This contract is not verified on the public explorer yet. Verified source improves read/write tooling, + ABI decoding, and auditability for{' '} + {address}. +

+
    +
  • + Forge batch (recommended):{' '} + {FORGE_VERIFY_COMMAND} +
  • +
  • + Operator guide:{' '} + + Blockscout verification guide + +
  • +
  • + Native Blockscout UI (LAN): use contract verification on the VM Blockscout instance when the custom explorer UI does not expose the form. +
  • +
+
+ ) +} diff --git a/frontend/src/components/home/HomePage.tsx b/frontend/src/components/home/HomePage.tsx index 80848a6..3089006 100644 --- a/frontend/src/components/home/HomePage.tsx +++ b/frontend/src/components/home/HomePage.tsx @@ -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([]) 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({ )} + {(relaySummary || bridgeStatus || stats) && ( +
+ + {chainStatus?.status ? : null} + {relaySummary ? : null} + + {latestTransactionAgeLabel} +
+ )} + {stats && ( -
-
+
+

Network overview

+
{primaryMetricCards.map((card) => (
{card.label}
@@ -763,48 +776,77 @@ export default function Home({ ))}
-
- {activityMetricCards.map((card) => ( - -
{card.label}
-
{card.value}
-
{card.note}
-
{card.detail}
-
- ))} -
+ - {mode === 'guided' ? ( -
- {secondaryMetricCards.map((card) => ( - -
{card.label}
-
{card.value}
-
{card.note}
-
- ))} -
- ) : ( - -
-
-
Telemetry Snapshot
-
- Secondary public stats in a denser expert layout. -
-
-
+ {statsDetailsExpanded ? ( + <> +
+ {activityMetricCards.map((card) => ( + +
{card.label}
+
{card.value}
+
{card.note}
+
{card.detail}
+
+ ))} +
+ + {mode === 'guided' ? ( +
{secondaryMetricCards.map((card) => ( -
-
{card.label}
-
{card.value}
-
{card.note}
-
+ +
{card.label}
+
{card.value}
+
{card.note}
+
))}
-
- - )} + ) : ( + +
+
+
Telemetry Snapshot
+
+ Secondary public stats in a denser expert layout. +
+
+
+ {secondaryMetricCards.map((card) => ( +
+
{card.label}
+
{card.value}
+
{card.note}
+
+ ))} +
+
+
+ )} + + + + + ) : null}
)} @@ -838,25 +880,6 @@ export default function Home({
) : null} -
- - -
- {!stats && (

@@ -865,7 +888,46 @@ export default function Home({ )} - +

+ + {recentTransactions.length === 0 ? ( +

+ Recent transactions are unavailable right now. +

+ ) : ( +
+ {recentTransactions.map((transaction) => ( +
+
+ + {transaction.hash.slice(0, 10)}...{transaction.hash.slice(-8)} + +
+ Block #{transaction.block_number} · from{' '} +
+ {transaction.to_address ? ( + <> + {' '}→
+ + ) : null} +
+
+
+
{formatWeiAsEth(transaction.value, 4)}
+
{formatTimestamp(transaction.created_at)}
+
+
+ ))} +
+ )} +
+ + View all transactions → + +
+
+ + {recentBlocks.length === 0 ? (

Recent blocks are unavailable right now. @@ -903,120 +965,35 @@ export default function Home({ View all blocks →

-
- -
- -

- {mode === 'guided' - ? 'A concise public view of chain activity, index coverage, and recent execution patterns.' - : 'Public chain activity and index posture.'} -

-
-
-
Latest Daily Volume
-
- {latestTrendPoint ? latestTrendPoint.transaction_count.toLocaleString() : 'Unknown'} -
-
{latestTrendPoint?.date || 'Trend feed unavailable'}
-
-
-
Recent Success Rate
-
- {activitySnapshot ? `${Math.round(activitySnapshot.success_rate * 100)}%` : 'Unknown'} -
-
- {activitySnapshot ? `${activitySnapshot.sample_size} sampled transactions` : 'Recent activity snapshot unavailable'} -
-
-
-
Avg Recent Fee
-
- {activitySnapshot ? formatWeiAsEth(Math.round(activitySnapshot.average_fee_wei).toString(), 6) : 'Unknown'} -
-
Average fee from the recent public sample.
-
-
-
Peak Charted Day
-
- {peakTrendPoint ? peakTrendPoint.transaction_count.toLocaleString() : 'Unknown'} -
-
{peakTrendPoint?.date || 'No trend data yet'}
-
-
-
- - Open full analytics → - -
-
- -

- Go directly to the explorer surfaces that provide the strongest operational and discovery context. -

-
- - Search → - - - Transactions → - - - Tokens → - - - Addresses → - - - Analytics → - -
-
- -

- Explore the public Chain 138 DODO PMM liquidity mesh, the canonical route matrix, and the - partner payload endpoints exposed through the explorer. -

-
- - Open routes and liquidity → - -
-
- -

- Add Chain 138, Ethereum Mainnet, and ALL Mainnet to MetaMask, then use the explorer token - list URL so supported tokens appear automatically. -

-
- - Open wallet tools → - -
-
- -

- Open the public bridge monitoring surface for relay status, mission-control links, bridge trace tooling, - and the visual command center entry points. -

-
- - Open bridge monitoring → - -
-
- -

- Open the public operations surface for wrapped-asset references, analytics shortcuts, operator links, - system topology views, and other Chain 138 support tools. -

-
- - Open operations hub → - -
+ + +

+ Jump to the explorer surfaces used most often for discovery, liquidity, wallet setup, and bridge monitoring. +

+
+ + Search + + + Tokens + + + Wallet & MetaMask + + + Liquidity & routes + + + Bridge monitoring + + + Analytics + +
+
+ ) } diff --git a/frontend/src/components/wallet/AddToMetaMask.tsx b/frontend/src/components/wallet/AddToMetaMask.tsx index 9a74d91..3c48c77 100644 --- a/frontend/src/components/wallet/AddToMetaMask.tsx +++ b/frontend/src/components/wallet/AddToMetaMask.tsx @@ -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[]) : []) } diff --git a/frontend/src/pages/addresses/[address].tsx b/frontend/src/pages/addresses/[address].tsx index 485d84c..9a2eebe 100644 --- a/frontend/src/pages/addresses/[address].tsx +++ b/frontend/src/pages/addresses/[address].tsx @@ -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() { + {addressInfo.is_contract ? ( + + ) : null} + {activeTab === 'contract' && addressInfo.is_contract && ( diff --git a/frontend/src/pages/search/index.tsx b/frontend/src/pages/search/index.tsx index 85f30c5..25a05ce 100644 --- a/frontend/src/pages/search/index.tsx +++ b/frontend/src/pages/search/index.tsx @@ -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 = 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) && diff --git a/frontend/src/pages/tokens/index.tsx b/frontend/src/pages/tokens/index.tsx index 73aab2c..f97fb9f 100644 --- a/frontend/src/pages/tokens/index.tsx +++ b/frontend/src/pages/tokens/index.tsx @@ -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) { +

+ {TOKEN_LIST_SURFACE_LABELS.catalog}. Showing {featuredCuratedTokens.length} featured tokens from the live report list. +

{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 = 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, } diff --git a/frontend/src/services/api/tokenListSurfaces.test.ts b/frontend/src/services/api/tokenListSurfaces.test.ts new file mode 100644 index 0000000..53374a8 --- /dev/null +++ b/frontend/src/services/api/tokenListSurfaces.test.ts @@ -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) + }) +}) diff --git a/frontend/src/services/api/tokenListSurfaces.ts b/frontend/src/services/api/tokenListSurfaces.ts new file mode 100644 index 0000000..3ad44b2 --- /dev/null +++ b/frontend/src/services/api/tokenListSurfaces.ts @@ -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 = { + 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 { + const lookup = new Map() + 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}` +} diff --git a/frontend/src/services/api/tokens.test.ts b/frontend/src/services/api/tokens.test.ts index 2e8a08a..ba9e977 100644 --- a/frontend/src/services/api/tokens.test.ts +++ b/frontend/src/services/api/tokens.test.ts @@ -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') }) }) diff --git a/frontend/src/services/api/tokens.ts b/frontend/src/services/api/tokens.ts index 0bef994..682092e 100644 --- a/frontend/src/services/api/tokens.ts +++ b/frontend/src/services/api/tokens.ts @@ -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> { - const response = await configApi.getTokenList() - const lookup = new Map() - 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: [] } diff --git a/scripts/e2e-sprint-smoke.spec.ts b/scripts/e2e-sprint-smoke.spec.ts new file mode 100644 index 0000000..4f0032b --- /dev/null +++ b/scripts/e2e-sprint-smoke.spec.ts @@ -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 }) + }) +})