+
+ {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