From 01481440f1da420cfc55f531f58cafe4ff932af6 Mon Sep 17 00:00:00 2001 From: Enrico Rubboli Date: Thu, 15 Oct 2026 20:48:25 +0200 Subject: [PATCH] feat: import explorer-verified tokens into the pair list --- app/src/components/OrderBook.tsx | 26 +-- app/src/components/TokenSearch.test.tsx | 94 ++++++++ app/src/components/TokenSearch.tsx | 273 ++++++++++++++++++---- app/src/lib/explorer-tokens-route.test.ts | 207 ++++++++++++++++ app/src/lib/token-info-client.test.ts | 75 ++++++ app/src/lib/token-info-client.ts | 53 +++++ app/src/pages/api/explorer-tokens.ts | 196 ++++++++++++++++ app/src/pages/api/prefs.test.ts | 51 ++++ app/src/pages/api/prefs.ts | 43 +++- 9 files changed, 949 insertions(+), 69 deletions(-) create mode 100644 app/src/components/TokenSearch.test.tsx create mode 100644 app/src/lib/explorer-tokens-route.test.ts create mode 100644 app/src/lib/token-info-client.test.ts create mode 100644 app/src/lib/token-info-client.ts create mode 100644 app/src/pages/api/explorer-tokens.ts diff --git a/app/src/components/OrderBook.tsx b/app/src/components/OrderBook.tsx index 54bd551..86137a3 100644 --- a/app/src/components/OrderBook.tsx +++ b/app/src/components/OrderBook.tsx @@ -6,6 +6,7 @@ import { submitWithToast } from "@/lib/toastStore"; import { CopyButton } from "@/components/CopyButton"; import type { OrderInfo, TokenCurrency } from "@/lib/wallet-rpc"; import { rpc } from '@/lib/client-rpc'; +import { getTokensInfoByIds } from '@/lib/token-info-client'; import { stakeTrade } from '@/lib/stake-trade-client'; import { TotpField } from '@/components/ui/TotpField'; import { aggregateLevels, fmtQty } from '@/components/orderBookDepth'; @@ -812,22 +813,15 @@ export default function OrderBook({ initialOwnOrders, balanceTokens = [], initia // Resolve types and tickers for all favourites. // NFTs are filtered out - the order book only supports fungible token pairs. - // node_get_tokens_info does not preserve input order, so call one at a time. const allIds = favs.map(f => f.tokenId); if (allIds.length === 0) { setFavourites(favs); return; } try { - const results = await Promise.all( - allIds.map(id => - rpc<[{ type: string; content: { token_ticker?: { text: string | null }; metadata?: { ticker?: { text: string | null }; name?: { text: string | null } } } } | null]>( - "node_get_tokens_info", { token_ids: [id] } - ).then(([info]) => ({ id, info: info ?? null })) - .catch(() => ({ id, info: null as null })) - ) - ); + // Batched calls, aligned by each result's own token_id. + const infoMap = await getTokensInfoByIds(allIds); const tickerUpdates = new Map(); const nftIds = new Set(); - results.forEach(({ id, info }) => { + infoMap.forEach((info, id) => { if (!info) return; if (info.type !== "FungibleToken") { nftIds.add(id); return; } const ticker = info.content.token_ticker?.text ?? null; @@ -882,16 +876,10 @@ export default function OrderBook({ initialOwnOrders, balanceTokens = [], initia unknownIds.forEach(id => resolvedTokenIds.current.add(id)); - Promise.all( - unknownIds.map(id => - rpc<[{ type: string; content: { token_ticker?: { text: string | null }; metadata?: { ticker?: { text: string | null }; name?: { text: string | null } } } } | null]>( - 'node_get_tokens_info', { token_ids: [id] } - ).then(([info]) => ({ id, info: info ?? null })) - .catch(() => ({ id, info: null as null })) - ) - ).then(results => { + // Batched calls, aligned by each result's own token_id. + getTokensInfoByIds(unknownIds).then(infoMap => { const newMap = new Map(); - results.forEach(({ id, info }) => { + infoMap.forEach((info, id) => { if (!info) return; const ticker = info.type === 'FungibleToken' ? (info.content.token_ticker?.text ?? null) diff --git a/app/src/components/TokenSearch.test.tsx b/app/src/components/TokenSearch.test.tsx new file mode 100644 index 0000000..76459ec --- /dev/null +++ b/app/src/components/TokenSearch.test.tsx @@ -0,0 +1,94 @@ +// @vitest-environment jsdom +/** Tests for the verified-token bulk import section in TokenSearch. */ + +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import TokenSearch from './TokenSearch'; + +vi.mock('@/lib/client-rpc', () => ({ + rpc: vi.fn().mockResolvedValue([]), +})); + +const verifiedRows = { + ok: true, + tokens: [ + { + tokenId: 'tmlaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa1', + ticker: 'AAA', + displayName: 'Alpha Token', + categories: ['defi'], + circulatingSupply: '1,000', + decimals: 18, + }, + { + tokenId: 'tmlbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb2', + ticker: 'BBB', + displayName: 'Beta Token', + categories: [], + circulatingSupply: '2,000', + decimals: 8, + }, + ], +}; + +const fetchMock = vi.fn(); + +function jsonResponse(body: unknown): Response { + return new Response(JSON.stringify(body), { + status: 200, + headers: { 'content-type': 'application/json' }, + }); +} + +beforeEach(() => { + fetchMock.mockReset(); + fetchMock.mockImplementation((url: string, init?: RequestInit) => { + if (url === '/api/indexer-status') return Promise.resolve(jsonResponse({ up: true })); + if (url === '/api/explorer-tokens') return Promise.resolve(jsonResponse(verifiedRows)); + if (url === '/api/prefs') { + if (init?.method === 'POST') return Promise.resolve(jsonResponse({ ok: true })); + return Promise.resolve(jsonResponse({ ok: true, value: [] })); + } + return Promise.reject(new Error(`unexpected fetch ${url}`)); + }); + vi.stubGlobal('fetch', fetchMock); +}); + +describe('TokenSearch verified-token import', () => { + it('loads the verified list and imports the selection into favourites', async () => { + const user = userEvent.setup(); + render(); + + await user.click(screen.getByRole('button', { name: 'Browse verified tokens' })); + expect(await screen.findByText('Alpha Token')).toBeInTheDocument(); + expect(screen.getByText('Beta Token')).toBeInTheDocument(); + + await user.click(screen.getByRole('checkbox', { name: /select all/i })); + await user.click(screen.getByRole('button', { name: /import selected \(2\)/i })); + + expect(await screen.findByText(/Imported 2 tokens\./)).toBeInTheDocument(); + + const post = fetchMock.mock.calls.find(([, init]) => init?.method === 'POST'); + expect(post).toBeDefined(); + const body = JSON.parse(String((post?.[1] as RequestInit).body)) as Array>; + expect(body.map((e) => e.ticker)).toEqual(['AAA', 'BBB']); + expect(body[0]?.tokenId).toContain('aaaa'); + }); + + it('reports the unavailability of the explorer list', async () => { + const user = userEvent.setup(); + fetchMock.mockImplementation((url: string) => { + if (url === '/api/indexer-status') return Promise.resolve(jsonResponse({ up: true })); + if (url === '/api/explorer-tokens') { + return Promise.resolve(jsonResponse({ ok: false, error: 'Token list is unavailable' })); + } + if (url === '/api/prefs') return Promise.resolve(jsonResponse({ ok: true, value: [] })); + return Promise.reject(new Error(`unexpected fetch ${url}`)); + }); + + render(); + await user.click(screen.getByRole('button', { name: 'Browse verified tokens' })); + expect(await screen.findByText('Token list is unavailable')).toBeInTheDocument(); + }); +}); diff --git a/app/src/components/TokenSearch.tsx b/app/src/components/TokenSearch.tsx index fcd9557..505e92c 100644 --- a/app/src/components/TokenSearch.tsx +++ b/app/src/components/TokenSearch.tsx @@ -4,7 +4,7 @@ import { hexToText } from '@/lib/token-utils'; import { safeImageUri } from '@/lib/safe-uri'; import { CopyButton } from '@/components/CopyButton'; import { TokenIdTooltip } from '@/components/TokenIdTooltip'; -import { rpc } from '@/lib/client-rpc'; +import { getTokensInfoByIds } from '@/lib/token-info-client'; interface SearchResult { tokenId: string; @@ -16,6 +16,18 @@ interface FavouriteEntry { ticker: string; } +interface ExplorerTokenRow { + tokenId: string; + ticker: string; + displayName: string | null; + categories: string[]; + circulatingSupply: string; + decimals: number; +} + +/** Hard cap mirrored by the server (server allows 1000, we stay tidy). */ +const FAVOURITES_CAP = 500; + interface Props { network: string; } @@ -147,6 +159,16 @@ export default function TokenSearch({ network }: Props) { const [favInfos, setFavInfos] = useState>(new Map()); const [favLoading, setFavLoading] = useState(false); + // Verified-token bulk import state + const [vtOpen, setVtOpen] = useState(false); + const [vtRows, setVtRows] = useState(null); + const [vtLoading, setVtLoading] = useState(false); + const [vtError, setVtError] = useState(null); + const [vtSelected, setVtSelected] = useState>(new Set()); + const [vtImporting, setVtImporting] = useState(false); + const [vtNote, setVtNote] = useState(null); + const [saveError, setSaveError] = useState(null); + const explorerBase = network === 'testnet' ? 'https://lovelace.explorer.mintlayer.org' : 'https://explorer.mintlayer.org'; @@ -173,17 +195,8 @@ export default function TokenSearch({ network }: Props) { if (ids.length === 0) return; setFavLoading(true); try { - // node_get_tokens_info does not preserve input order - call one at a time so - // each result is guaranteed to correspond to the correct token ID. - const results = await Promise.all( - ids.map(id => - rpc<(TokenInfo | null)[]>('node_get_tokens_info', { token_ids: [id] }) - .then(([info]) => ({ id, info: info ?? null })) - ) - ); - const map = new Map(); - results.forEach(({ id, info }) => map.set(id, info)); - setFavInfos(map); + // Batched calls, aligned by each result's own token_id. + setFavInfos(await getTokensInfoByIds(ids)); } catch { // silently fail } finally { @@ -191,35 +204,48 @@ export default function TokenSearch({ network }: Props) { } } - function toggleFavourite(tokenId: string, ticker: string) { - setFavourites(prev => { - const alreadyPinned = prev.some(f => f.tokenId === tokenId); - const next = alreadyPinned - ? prev.filter(f => f.tokenId !== tokenId) - : [...prev, { tokenId, ticker }]; - fetch('/api/prefs', { + async function toggleFavourite(tokenId: string, ticker: string) { + const alreadyPinned = favourites.some(f => f.tokenId === tokenId); + let next: FavouriteEntry[]; + if (alreadyPinned) { + next = favourites.filter(f => f.tokenId !== tokenId); + } else { + if (favourites.length >= FAVOURITES_CAP) { + setSaveError(`Your list is at its cap (${FAVOURITES_CAP}) — unpin something first.`); + return; + } + next = [...favourites, { tokenId, ticker }]; + } + // Persist first, then commit — the UI never claims a state the store + // doesn't hold, and a later import can't overwrite a failed toggle. + try { + const res = await fetch('/api/prefs', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(next), - }).catch(() => {}); - - if (!alreadyPinned) { - // Fetch fresh info for newly pinned token if not already cached - setFavInfos(current => { - if (!current.has(tokenId)) { - const existing = results.find(r => r.tokenId === tokenId); - if (existing) { - const updated = new Map(current); - updated.set(tokenId, existing.info); - return updated; - } + }); + const data = await res.json() as { ok: boolean; error?: string }; + if (!data.ok) throw new Error(data.error ?? 'Saving failed'); + } catch (err) { + setSaveError(`Could not save your list: ${(err as Error).message}`); + return; + } + setSaveError(null); + setFavourites(next); + if (!alreadyPinned) { + // Fetch fresh info for newly pinned token if not already cached + setFavInfos(current => { + if (!current.has(tokenId)) { + const existing = results.find(r => r.tokenId === tokenId); + if (existing) { + const updated = new Map(current); + updated.set(tokenId, existing.info); + return updated; } - return current; - }); - } - - return next; - }); + } + return current; + }); + } } async function handleSearch(e: React.FormEvent) { @@ -236,7 +262,7 @@ export default function TokenSearch({ network }: Props) { let tokenIds: string[] = []; if (looksLikeTokenId(q)) { - tokenIds = [q]; + tokenIds = [q.slice(0, 128)]; } else { if (!indexerAvailable) { setError('Ticker search requires the indexer. Paste a full token ID to look up directly.'); @@ -249,22 +275,19 @@ export default function TokenSearch({ network }: Props) { tokenIds = data.result ?? []; } - // Deduplicate - tokenIds = [...new Set(tokenIds)]; + // Deduplicate and drop non-id junk before it reaches the RPC layer. + tokenIds = [...new Set(tokenIds)].filter( + id => typeof id === 'string' && id.length > 0 && id.length <= 128, + ); if (tokenIds.length === 0) { setLoading(false); return; } - // node_get_tokens_info does not preserve input order - call one at a time. - const infoResults = await Promise.all( - tokenIds.map(id => - rpc<(TokenInfo | null)[]>('node_get_tokens_info', { token_ids: [id] }) - .then(([info]) => ({ id, info: info ?? null })) - ) - ); - setResults(infoResults.map(({ id, info }) => ({ tokenId: id, info }))); + // Batched calls, aligned by each result's own token_id. + const infoMap = await getTokensInfoByIds(tokenIds); + setResults(tokenIds.map(id => ({ tokenId: id, info: infoMap.get(id) ?? null }))); } catch (err) { setError((err as Error).message); } finally { @@ -274,8 +297,162 @@ export default function TokenSearch({ network }: Props) { const favSet = new Set(favourites.map(f => f.tokenId)); + async function loadVerified() { + setVtOpen(true); + setVtLoading(true); + setVtError(null); + setVtNote(null); + try { + const res = await fetch('/api/explorer-tokens'); + const data = await res.json() as { ok: boolean; tokens?: ExplorerTokenRow[]; error?: string }; + if (!data.ok) throw new Error(data.error ?? 'Could not load the verified token list'); + setVtRows(data.tokens ?? []); + setVtSelected(new Set()); + } catch (err) { + setVtError((err as Error).message); + } finally { + setVtLoading(false); + } + } + + async function importVerified() { + if (!vtRows) return; + const picked = vtRows.filter(r => vtSelected.has(r.tokenId)); + if (picked.length === 0) return; + setVtImporting(true); + setVtNote(null); + try { + const existing = new Set(favourites.map(f => f.tokenId)); + const additions = picked.filter(r => !existing.has(r.tokenId)); + const room = Math.max(0, FAVOURITES_CAP - favourites.length); + const accepted = additions.slice(0, room); + if (accepted.length === 0) { + setVtNote('Nothing to import — these tokens are already in your list.'); + return; + } + const next: FavouriteEntry[] = [ + ...favourites, + ...accepted.map(r => ({ tokenId: r.tokenId, ticker: r.ticker })), + ]; + const res = await fetch('/api/prefs', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(next), + }); + const data = await res.json() as { ok: boolean; error?: string }; + if (!data.ok) throw new Error(data.error ?? 'Saving failed'); + setFavourites(next); + fetchFavInfos(next.map(f => f.tokenId)); + setVtNote(additions.length > accepted.length + ? `Imported ${accepted.length} tokens — the list cap was reached.` + : `Imported ${accepted.length} token${accepted.length === 1 ? '' : 's'}.`); + setVtSelected(new Set()); + } catch (err) { + setVtError((err as Error).message); + } finally { + setVtImporting(false); + } + } + + function toggleVtRow(tokenId: string) { + setVtSelected(prev => { + const next = new Set(prev); + if (next.has(tokenId)) next.delete(tokenId); + else next.add(tokenId); + return next; + }); + } + + function toggleVtAll() { + if (!vtRows) return; + setVtSelected(prev => (prev.size === vtRows.length ? new Set() : new Set(vtRows.map(r => r.tokenId)))); + } + return (
+ {/* ── Verified-token bulk import ───────────────────────────────── */} +
+
+ + + From the Mintlayer explorer's verified list — always check a token before trading it. + +
+ + {vtError &&

{vtError}

} + {saveError &&

{saveError}

} + {vtNote &&

{vtNote}

} + + {vtOpen && vtRows !== null && ( + vtRows.length === 0 ? ( +

No verified tokens published yet.

+ ) : ( + <> +
+ + +
+
    + {vtRows.map(r => { + const checked = vtSelected.has(r.tokenId); + const already = favSet.has(r.tokenId); + return ( +
  • + +
  • + ); + })} +
+ + ) + )} +
+ {/* ── Search form ────────────────────────────────────────────────────── */}
= {}) { + return { + id: 'tmltoken1234567890abcdef', + ticker: 'tst', + displayName: 'Test Token', + categories: ['meme'], + circulatingSupply: '1,000,000', + decimals: 18, + verified: true, + ...overrides, + }; +} + +const ENV_KEYS = ['NETWORK', 'EXPLORER_TOKENS_API_BASE'] as const; + +/** The route only reads the Request body/none — cast past APIContext. */ +function call(req: Request): ReturnType { + return GET(req as unknown as Parameters[0]); +} + +function stubEnv(values: Partial>): void { + for (const k of ENV_KEYS) { + if (values[k] === undefined) delete process.env[k]; + else process.env[k] = values[k]; + } +} + +let fetchMock: ReturnType; + +describe('explorer-tokens route', () => { + beforeEach(() => { + clearExplorerTokensCache(); + fetchMock = vi.fn(); + vi.stubGlobal('fetch', fetchMock); + }); + + afterEach(() => { + stubEnv({}); + vi.unstubAllGlobals(); + }); + + it('maps NETWORK to the fixed explorer hosts', () => { + expect(explorerTokensBase('mainnet')).toBe(MAINNET); + expect(explorerTokensBase('testnet')).toBe(TESTNET); + expect(explorerTokensBase(undefined)).toBe(MAINNET); + expect(explorerTokensBase('bogus')).toBe(MAINNET); + process.env.EXPLORER_TOKENS_API_BASE = 'https://explorer.example.org/'; + expect(explorerTokensBase('mainnet')).toBe('https://explorer.example.org'); + process.env.EXPLORER_TOKENS_API_BASE = 'http://insecure.example.org'; + expect(explorerTokensBase('mainnet')).toBeNull(); + }); + + it('merges both filters, keeps verified rows only, emits lean rows', async () => { + stubEnv({ NETWORK: 'mainnet' }); + fetchMock + .mockResolvedValueOnce(jsonResponse([row(), row({ id: 'raw1', verified: false }), 'junk'])) + .mockResolvedValueOnce( + jsonResponse([row({ id: 'lock1', ticker: '', categories: Array(10).fill('x') })]), + ); + + const res = await call(new Request('http://localhost/api/explorer-tokens')); + expect(res.status).toBe(200); + const body = (await res.json()) as { ok: boolean; tokens: unknown[] }; + expect(body.ok).toBe(true); + expect(body.tokens).toHaveLength(2); + + const first = body.tokens[0] as Record; + expect(first).toEqual({ + tokenId: 'tmltoken1234567890abcdef', + ticker: 'tst', + displayName: 'Test Token', + categories: ['meme'], + circulatingSupply: '1,000,000', + decimals: 18, + }); + // Empty ticker falls back to the first four id characters. + const second = body.tokens[1] as Record; + expect(second.ticker).toBe('LOCK'); + expect((second.categories as unknown[]).length).toBeLessThanOrEqual(8); + + expect(fetchMock).toHaveBeenCalledTimes(2); + expect(String(fetchMock.mock.calls[0]?.[0])).toBe(`${MAINNET}/api/tokens-search?filter=Fixed`); + expect(String(fetchMock.mock.calls[1]?.[0])).toBe( + `${MAINNET}/api/tokens-search?filter=Lockable`, + ); + }); + + it('serves from cache within the TTL without refetching', async () => { + stubEnv({ NETWORK: 'mainnet' }); + fetchMock + .mockResolvedValueOnce(jsonResponse([row()])) + .mockResolvedValueOnce(jsonResponse([])); + + await call(new Request('http://localhost/api/explorer-tokens')); + await call(new Request('http://localhost/api/explorer-tokens')); + expect(fetchMock).toHaveBeenCalledTimes(2); + }); + + it('rejects literal null / non-array bodies with 502', async () => { + stubEnv({ NETWORK: 'mainnet' }); + fetchMock.mockImplementation(() => Promise.resolve(jsonResponse(null))); + + const res = await call(new Request('http://localhost/api/explorer-tokens')); + expect(res.status).toBe(502); + const body = (await res.json()) as { ok: boolean }; + expect(body.ok).toBe(false); + }); + + it('rejects HTML responses (old-build 404 pages) with 502', async () => { + stubEnv({ NETWORK: 'mainnet' }); + fetchMock.mockResolvedValue( + new Response('404', { + status: 200, + headers: { 'content-type': 'text/html' }, + }), + ); + + const res = await call(new Request('http://localhost/api/explorer-tokens')); + expect(res.status).toBe(502); + }); + + it('refuses oversized declared bodies before reading them', async () => { + stubEnv({ NETWORK: 'mainnet' }); + fetchMock.mockResolvedValue( + new Response('[]', { + status: 200, + headers: { 'content-type': 'application/json', 'content-length': String(64 * 1024 * 1024) }, + }), + ); + + const res = await call(new Request('http://localhost/api/explorer-tokens')); + expect(res.status).toBe(502); + }); + + it('refuses chunked bodies that grow past the cap while streaming', async () => { + stubEnv({ NETWORK: 'mainnet' }); + const big = 'x'.repeat(3 * 1024 * 1024); + fetchMock.mockImplementation(() => + Promise.resolve( + new Response(big, { + status: 200, + // Deliberately no content-length header (chunked transfer). + headers: { 'content-type': 'application/json' }, + }), + ), + ); + + const res = await call(new Request('http://localhost/api/explorer-tokens')); + expect(res.status).toBe(502); + }); + + it('shares one upstream refresh across concurrent requests', async () => { + stubEnv({ NETWORK: 'mainnet' }); + let calls = 0; + fetchMock.mockImplementation(() => { + calls += 1; + return new Promise((resolve) => { + setTimeout(() => resolve(jsonResponse([])), 20); + }); + }); + + await Promise.all([ + call(new Request('http://localhost/api/explorer-tokens')), + call(new Request('http://localhost/api/explorer-tokens')), + call(new Request('http://localhost/api/explorer-tokens')), + ]); + // 3 concurrent requests share one refresh = exactly 2 filter fetches. + expect(calls).toBe(2); + }); + + it('reports network failures as 502', async () => { + stubEnv({ NETWORK: 'mainnet' }); + fetchMock.mockRejectedValue(new TypeError('fetch failed')); + + const res = await call(new Request('http://localhost/api/explorer-tokens')); + expect(res.status).toBe(502); + }); + + it('answers 500 when the configured base is invalid', async () => { + stubEnv({ NETWORK: 'mainnet', EXPLORER_TOKENS_API_BASE: 'not a url' }); + const res = await call(new Request('http://localhost/api/explorer-tokens')); + expect(res.status).toBe(500); + expect(fetchMock).not.toHaveBeenCalled(); + }); +}); diff --git a/app/src/lib/token-info-client.test.ts b/app/src/lib/token-info-client.test.ts new file mode 100644 index 0000000..52ed1a9 --- /dev/null +++ b/app/src/lib/token-info-client.test.ts @@ -0,0 +1,75 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { getTokensInfoByIds, TOKEN_INFO_CHUNK_SIZE } from './token-info-client'; +import type { TokenInfo } from './wallet-rpc'; + +vi.mock('@/lib/client-rpc', () => ({ + rpc: vi.fn(), +})); + +import { rpc } from '@/lib/client-rpc'; +const rpcMock = vi.mocked(rpc); + +function fungible(tokenId: string): TokenInfo { + return { + type: 'FungibleToken', + content: { + token_id: tokenId, + token_ticker: { text: 'T', hex: '0x54' }, + number_of_decimals: 18, + metadata_uri: { text: null, hex: '' }, + circulating_supply: { atoms: '0' }, + total_supply: { type: 'Unlimited' }, + is_locked: false, + frozen: { type: 'NotFrozen' }, + authority: '0xdeadbeef', + }, + } as unknown as TokenInfo; +} + +describe('getTokensInfoByIds', () => { + beforeEach(() => { + rpcMock.mockReset(); + }); + + it('aligns shuffled responses by content.token_id', async () => { + rpcMock.mockResolvedValueOnce([fungible('b'), null, fungible('a')]); + const map = await getTokensInfoByIds(['a', 'b', 'c']); + expect(map.get('a')?.type).toBe('FungibleToken'); + expect(map.get('b')?.type).toBe('FungibleToken'); + expect(map.get('c')).toBeNull(); + expect(rpcMock).toHaveBeenCalledTimes(1); + expect(rpcMock.mock.calls[0]?.[1]).toEqual({ token_ids: ['a', 'b', 'c'] }); + }); + + it('chunks large id lists and deduplicates', async () => { + const ids = Array.from({ length: TOKEN_INFO_CHUNK_SIZE + 7 }, (_, i) => `id${i}`); + rpcMock.mockImplementation( + (_m: string, p?: Record) => + Promise.resolve( + ((p as { token_ids: string[] }).token_ids).map((id) => fungible(id)), + ), + ); + const map = await getTokensInfoByIds([...ids, 'id0']); + expect(rpcMock).toHaveBeenCalledTimes(2); + expect(map.size).toBe(ids.length); + expect(map.get('id0')?.type).toBe('FungibleToken'); + expect(map.get('id56')?.type).toBe('FungibleToken'); + }); + + it('keeps other chunks resolvable when one chunk fails', async () => { + const ids = Array.from({ length: TOKEN_INFO_CHUNK_SIZE + 1 }, (_, i) => `id${i}`); + rpcMock + .mockRejectedValueOnce(new Error('boom')) + .mockResolvedValueOnce([fungible(`id${TOKEN_INFO_CHUNK_SIZE}`)]); + const map = await getTokensInfoByIds(ids); + expect(map.get('id0')).toBeNull(); + expect(map.get(`id${TOKEN_INFO_CHUNK_SIZE}`)?.type).toBe('FungibleToken'); + }); + + it('handles a failing rpc gracefully for a single chunk', async () => { + rpcMock.mockRejectedValue(new Error('down')); + const map = await getTokensInfoByIds(['x', 'y']); + expect(map.get('x')).toBeNull(); + expect(map.get('y')).toBeNull(); + }); +}); diff --git a/app/src/lib/token-info-client.ts b/app/src/lib/token-info-client.ts new file mode 100644 index 0000000..ec21669 --- /dev/null +++ b/app/src/lib/token-info-client.ts @@ -0,0 +1,53 @@ +/** + * Batched token-info resolution for browser components. + * + * `node_get_tokens_info` does not preserve input order, but every result + * carries its own `content.token_id` — so instead of one RPC per token + * we call it in chunks and align the answers by that field. + */ + +import { rpc } from '@/lib/client-rpc'; +import type { TokenInfo } from '@/lib/wallet-rpc'; + +/** Chunk size: keeps each RPC payload modest regardless of list size. */ +export const TOKEN_INFO_CHUNK_SIZE = 50; + +function tokenIdOf(info: TokenInfo | null): string | null { + if (info === null) return null; + const content = (info as { content?: { token_id?: unknown } }).content; + return typeof content?.token_id === 'string' ? content.token_id : null; +} + +/** + * Resolves many token ids in batched calls. + * Returns a Map with an entry for every requested id (null when the + * chain answered nothing for it or an individual chunk failed). + */ +export async function getTokensInfoByIds( + ids: string[], +): Promise> { + const out = new Map(); + for (const id of ids) out.set(id, null); + + const unique = [...new Set(ids)]; + for (let i = 0; i < unique.length; i += TOKEN_INFO_CHUNK_SIZE) { + const chunk = unique.slice(i, i + TOKEN_INFO_CHUNK_SIZE); + try { + const infos = await rpc<(TokenInfo | null)[]>('node_get_tokens_info', { + token_ids: chunk, + }); + // Order is not preserved — index the answers by their own token id. + const byId = new Map(); + for (const info of infos ?? []) { + const tid = tokenIdOf(info); + if (tid !== null) byId.set(tid, info); + } + for (const id of chunk) { + if (byId.has(id)) out.set(id, byId.get(id) ?? null); + } + } catch { + // Chunk failed — remaining ids stay null; other chunks still resolve. + } + } + return out; +} diff --git a/app/src/pages/api/explorer-tokens.ts b/app/src/pages/api/explorer-tokens.ts new file mode 100644 index 0000000..f53209e --- /dev/null +++ b/app/src/pages/api/explorer-tokens.ts @@ -0,0 +1,196 @@ +/** + * GET /api/explorer-tokens — verified token list from the Mintlayer + * explorer's token API. + * + * Proxied server-side on purpose: the explorer hosts stay out of the + * browser CSP connect-src allowlist, the visitor's IP never reaches the + * explorer, and the response is validated once here instead of in every + * client. Only the fixed hosts below are fetchable — no user-supplied + * URLs, so this is not an open proxy. + * + * The staging builds predate the `verified` query param (it returns the + * JSON literal `null` there), so the route always fetches the two supply + * filters and keeps only rows with `verified === true` — identical + * behavior on old and new explorer builds. Results are cached briefly + * in-memory to respect the explorer's polling etiquette (≤1 req/10s). + * + * Row note: `circulatingSupply` is a display string (custody-adjusted) — + * it is passed through untouched, never parsed. + */ + +import type { APIRoute } from 'astro'; +import { json } from '@/lib/api-utils'; + +/** Per-fetch budget; two fetches per refresh, worst case 2× this. */ +const UPSTREAM_TIMEOUT_MS = 12_000; +/** ~7k rows of headroom — larger answers are refused, not truncated. */ +const MAX_RESPONSE_BYTES = 2 * 1024 * 1024; +/** Explorer etiquette: don't poll harder than ~1 req/10s. */ +const CACHE_TTL_MS = 60_000; +/** Bound for a single category string. */ +const MAX_CATEGORY_LENGTH = 32; + +const HOSTS: Record = { + mainnet: 'https://staging.explorer.mintlayer.org', + testnet: 'https://staging.lovelace.explorer.mintlayer.org', +}; + +export function explorerTokensBase(network?: string): string | null { + const override = process.env.EXPLORER_TOKENS_API_BASE; + if (override !== undefined && override !== '') { + const trimmed = override.replace(/\/+$/, ''); + return /^https:\/\/[a-z0-9.-]+(?::\d{1,5})?$/.test(trimmed) ? trimmed : null; + } + const host = HOSTS[(network ?? 'mainnet').toLowerCase()] ?? HOSTS['mainnet']; + return host ?? null; +} + +export interface ExplorerTokenRow { + tokenId: string; + ticker: string; + displayName: string | null; + categories: string[]; + circulatingSupply: string; + decimals: number; +} + +/** Validates one raw API row; null when anything is off (fail closed). */ +function asRow(raw: unknown): ExplorerTokenRow | null { + if (typeof raw !== 'object' || raw === null) return null; + const r = raw as Record; + if (typeof r.id !== 'string' || r.id.length === 0 || r.id.length > 128) return null; + if (r.verified !== true) return null; + const ticker = + typeof r.ticker === 'string' && r.ticker.length > 0 + ? r.ticker.slice(0, 12) + : r.id.slice(0, 4).toUpperCase(); + const displayName = + typeof r.displayName === 'string' && r.displayName.length > 0 + ? r.displayName.slice(0, 120) + : null; + const categories = Array.isArray(r.categories) + ? r.categories + .filter((c): c is string => typeof c === 'string' && c.length > 0) + .slice(0, 8) + .map((c) => c.slice(0, MAX_CATEGORY_LENGTH)) + : []; + const circulatingSupply = typeof r.circulatingSupply === 'string' ? r.circulatingSupply : '0'; + const decimals = + typeof r.decimals === 'number' && Number.isInteger(r.decimals) && r.decimals >= 0 && r.decimals <= 36 + ? r.decimals + : 0; + return { tokenId: r.id, ticker, displayName, categories, circulatingSupply, decimals }; +} + +/** Reads the body with a hard byte cap; null when the cap is exceeded. */ +async function readCapped(res: Response, cap: number): Promise { + if (res.body === null) return null; + const decoder = new TextDecoder(); + let text = ''; + let bytes = 0; + let reader: ReadableStreamDefaultReader; + try { + reader = res.body.getReader(); + for (;;) { + const { done, value } = await reader.read(); + if (done) break; + bytes += value.byteLength; + if (bytes > cap) { + void reader.cancel().catch(() => {}); + return null; + } + text += decoder.decode(value, { stream: true }); + } + text += decoder.decode(); + } catch { + return null; + } + return text; +} + +/** Fetches one supply-filter list; returns rows or null on failure. */ +async function fetchList(base: string, filter: string): Promise { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), UPSTREAM_TIMEOUT_MS); + let res: Response; + try { + res = await fetch(`${base}/api/tokens-search?filter=${encodeURIComponent(filter)}`, { + signal: controller.signal, + redirect: 'error', + headers: { accept: 'application/json' }, + }); + } catch { + clearTimeout(timer); + return null; + } + clearTimeout(timer); + + if (res.status !== 200) return null; + const contentType = (res.headers.get('content-type') ?? '').split(';')[0].trim(); + if (contentType !== 'application/json') return null; + const declared = Number(res.headers.get('content-length') ?? '0'); + if (Number.isFinite(declared) && declared > MAX_RESPONSE_BYTES) return null; + + // Streamed read with a hard cap — chunked responses without a + // content-length header can't blow the heap. + const text = await readCapped(res, MAX_RESPONSE_BYTES); + if (text === null) return null; + + let parsed: unknown; + try { + parsed = JSON.parse(text) as unknown; + } catch { + return null; + } + // Old builds answer a param-less/unknown-param request with `null`. + if (!Array.isArray(parsed)) return null; + return parsed + .map(asRow) + .filter((row): row is ExplorerTokenRow => row !== null); +} + +let cache: { at: number; rows: ExplorerTokenRow[] } | null = null; +let inFlight: Promise | null = null; + +/** Fetches both filter lists, merged by token id. */ +function fetchMergedRows(base: string): Promise { + return Promise.all([fetchList(base, 'Fixed'), fetchList(base, 'Lockable')]).then( + ([fixed, lockable]) => { + if (fixed === null || lockable === null) return null; + const byId = new Map(); + for (const row of [...fixed, ...lockable]) byId.set(row.tokenId, row); + return [...byId.values()]; + }, + ); +} + +/** Test hook: clears the module-level cache. */ +export function clearExplorerTokensCache(): void { + cache = null; +} + +export const GET: APIRoute = async () => { + const base = explorerTokensBase(process.env.NETWORK); + if (base === null) { + return json({ ok: false, error: 'Explorer token API is not configured' }, 500); + } + + if (cache !== null && Date.now() - cache.at < CACHE_TTL_MS) { + return json({ ok: true, tokens: cache.rows }); + } + + // Share one upstream refresh across concurrent requests (stampede + // guard); the TTL is stamped after completion, not at fetch start. + if (inFlight === null) { + inFlight = fetchMergedRows(base).finally(() => { + inFlight = null; + }); + } + const rows = await inFlight; + if (rows === null) { + return json({ ok: false, error: 'Token list is unavailable' }, 502); + } + + cache = { at: Date.now(), rows }; + return json({ ok: true, tokens: rows }); +}; diff --git a/app/src/pages/api/prefs.test.ts b/app/src/pages/api/prefs.test.ts index 9a1331b..c5e179d 100644 --- a/app/src/pages/api/prefs.test.ts +++ b/app/src/pages/api/prefs.test.ts @@ -32,6 +32,23 @@ describe('GET /api/prefs', () => { expect(body).toMatchObject({ ok: true, value: ['tokenA', 'tokenB'] }); }); + it('drops invalid entries from stored values on read', async () => { + vi.mocked(getPref).mockReturnValue([ + { tokenId: 'good1', ticker: 'G1' }, + { tokenId: 42 }, + 'legacy-string-id', + null, + { tokenId: 'good2', ticker: '' }, + ]); + const res = await GET(makeCtx()); + const body = await res.json(); + expect(res.status).toBe(200); + expect(body.value).toEqual([ + { tokenId: 'good1', ticker: 'G1' }, + 'legacy-string-id', + ]); + }); + it('returns empty array when nothing stored', async () => { vi.mocked(getPref).mockReturnValue(null); const res = await GET(makeCtx()); @@ -81,4 +98,38 @@ describe('POST /api/prefs', () => { const res = await POST(ctx); expect(res.status).toBe(500); }); + + it('accepts {tokenId, ticker} entries and passes them through', async () => { + const entries = [{ tokenId: 'tmlabc123', ticker: 'ABC' }]; + const res = await POST(makeCtx(entries)); + expect(res.status).toBe(200); + expect(setPref).toHaveBeenCalledWith('ml_favourite_tokens', entries); + }); + + it('rejects malformed entries with 400', async () => { + for (const bad of [[42], [{ tokenId: 'x' }], [{ ticker: 'NOTOKENID' }], [{ tokenId: 'ok', ticker: '' }], [{ tokenId: 'ok', ticker: 9 }]]) { + const res = await POST(makeCtx(bad)); + expect(res.status).toBe(400); + } + expect(setPref).not.toHaveBeenCalled(); + }); + + it('rejects arrays beyond the cap with 400', async () => { + const big = Array.from({ length: 1001 }, (_, i) => `token${i}`); + const res = await POST(makeCtx(big)); + expect(res.status).toBe(400); + expect(setPref).not.toHaveBeenCalled(); + }); + + it('rejects absurd declared body sizes with 413 before parsing', async () => { + const ctx = { + request: new Request('http://localhost/api/prefs', { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'Content-Length': '99999999' }, + body: '[]', + }), + } as Parameters[0]; + const res = await POST(ctx); + expect(res.status).toBe(413); + }); }); diff --git a/app/src/pages/api/prefs.ts b/app/src/pages/api/prefs.ts index ae6bbc4..19abff6 100644 --- a/app/src/pages/api/prefs.ts +++ b/app/src/pages/api/prefs.ts @@ -3,10 +3,35 @@ import { getPref, setPref } from '@/lib/prefs-db'; import { json } from '@/lib/api-utils'; const KEY = 'ml_favourite_tokens'; +/** Safety cap — the mass import stays far below this client-side. */ +const MAX_ENTRIES = 1000; + +function isId(value: unknown): value is string { + return typeof value === 'string' && value.length > 0 && value.length <= 128; +} + +/** + * Validates one favourite entry. Accepts the current `{tokenId, ticker}` + * shape and the legacy plain-string shape; anything else is rejected so + * garbage can't be written into the shared store. + */ +function asFavouriteEntry(value: unknown): unknown | null { + if (isId(value)) return value; + if (typeof value !== 'object' || value === null) return null; + const e = value as Record; + if (!isId(e.tokenId)) return null; + if (typeof e.ticker !== 'string' || e.ticker.length === 0 || e.ticker.length > 32) return null; + return { tokenId: e.tokenId, ticker: e.ticker }; +} export const GET: APIRoute = () => { try { - const value = getPref(KEY) ?? []; + const stored = getPref(KEY) ?? []; + // Defense-in-depth: serve only entries that pass the same validation + // as POST (row may predate the validation or be hand-edited). + const value = Array.isArray(stored) + ? stored.filter((e) => asFavouriteEntry(e) !== null) + : []; return json({ ok: true, value }); } catch (err) { return json({ ok: false, error: String(err) }, 500); @@ -15,9 +40,23 @@ export const GET: APIRoute = () => { export const POST: APIRoute = async ({ request }) => { try { + const declared = Number(request.headers.get('content-length') ?? '0'); + // 1000 entries × ~160 bytes ≪ 1MiB; refuse absurd bodies before parsing. + if (Number.isFinite(declared) && declared > 1024 * 1024) { + return json({ ok: false, error: 'Request body too large' }, 413); + } const body = await request.json(); if (!Array.isArray(body)) return json({ ok: false, error: 'Expected array' }, 400); - setPref(KEY, body); + if (body.length > MAX_ENTRIES) { + return json({ ok: false, error: `Too many entries (max ${MAX_ENTRIES})` }, 400); + } + const validated: unknown[] = []; + for (const raw of body) { + const entry = asFavouriteEntry(raw); + if (entry === null) return json({ ok: false, error: 'Invalid favourite entry' }, 400); + validated.push(entry); + } + setPref(KEY, validated); return json({ ok: true }); } catch (err) { return json({ ok: false, error: String(err) }, 500);