diff --git a/app/src/components/TokenSearch.test.tsx b/app/src/components/TokenSearch.test.tsx index 76459ec..3c282d7 100644 --- a/app/src/components/TokenSearch.test.tsx +++ b/app/src/components/TokenSearch.test.tsx @@ -91,4 +91,27 @@ describe('TokenSearch verified-token import', () => { await user.click(screen.getByRole('button', { name: 'Browse verified tokens' })); expect(await screen.findByText('Token list is unavailable')).toBeInTheDocument(); }); + + it('shows the stored ticker for favourites while on-chain info is unavailable', async () => { + const user = userEvent.setup(); + const favouriteId = 'tmlcccccccccccccccccccccccccccccccccccccccccc3'; + 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({ ok: true, tokens: [] })); + if (url === '/api/prefs') { + if (init?.method === 'POST') return Promise.resolve(jsonResponse({ ok: true })); + return Promise.resolve( + jsonResponse({ ok: true, value: [{ tokenId: favouriteId, ticker: 'OLDFAV' }] }), + ); + } + return Promise.reject(new Error(`unexpected fetch ${url}`)); + }); + // Token info lookup fails entirely (wallet daemon down). + const { rpc } = await import('@/lib/client-rpc'); + (rpc as ReturnType).mockRejectedValue(new Error('daemon down')); + + render(); + expect(await screen.findByText('OLDFAV')).toBeInTheDocument(); + expect(screen.queryByText('???')).not.toBeInTheDocument(); + }); }); diff --git a/app/src/components/TokenSearch.tsx b/app/src/components/TokenSearch.tsx index 505e92c..bbef1e9 100644 --- a/app/src/components/TokenSearch.tsx +++ b/app/src/components/TokenSearch.tsx @@ -64,11 +64,13 @@ interface TokenCardProps { explorerBase: string; isFavourite: boolean; onToggleFavourite: (tokenId: string, ticker: string) => void; + /** Stored ticker shown while on-chain info is unavailable (daemon down). */ + fallbackTicker?: string; } -function TokenCard({ tokenId, info, explorerBase, isFavourite, onToggleFavourite }: TokenCardProps) { +function TokenCard({ tokenId, info, explorerBase, isFavourite, onToggleFavourite, fallbackTicker }: TokenCardProps) { const isNFT = info?.type === 'NonFungibleToken'; - const ticker = !info ? '???' : + const ticker = !info ? (fallbackTicker || '???') : isNFT ? (hexToText(info.content.metadata.ticker) ?? hexToText(info.content.metadata.name) ?? 'NFT') : (hexToText(info.content.token_ticker) ?? '???'); const frozen = !isNFT && info?.type === 'FungibleToken' && info.content.frozen?.type === 'Frozen'; @@ -214,7 +216,7 @@ export default function TokenSearch({ network }: Props) { setSaveError(`Your list is at its cap (${FAVOURITES_CAP}) — unpin something first.`); return; } - next = [...favourites, { tokenId, ticker }]; + next = [...favourites, { tokenId, ticker: ticker === '???' ? `${tokenId.slice(0, 8)}…` : 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. @@ -526,6 +528,7 @@ export default function TokenSearch({ network }: Props) { explorerBase={explorerBase} isFavourite={true} onToggleFavourite={toggleFavourite} + fallbackTicker={ticker} /> ); })} diff --git a/app/src/pages/api/prefs.test.ts b/app/src/pages/api/prefs.test.ts index c5e179d..5389577 100644 --- a/app/src/pages/api/prefs.test.ts +++ b/app/src/pages/api/prefs.test.ts @@ -24,12 +24,18 @@ beforeEach(() => { }); describe('GET /api/prefs', () => { - it('returns stored favourites', async () => { + it('returns stored favourites (legacy strings normalized to objects)', async () => { vi.mocked(getPref).mockReturnValue(['tokenA', 'tokenB']); const res = await GET(makeCtx()); const body = await res.json(); expect(res.status).toBe(200); - expect(body).toMatchObject({ ok: true, value: ['tokenA', 'tokenB'] }); + expect(body).toMatchObject({ + ok: true, + value: [ + { tokenId: 'tokenA', ticker: 'tokenA…' }, + { tokenId: 'tokenB', ticker: 'tokenB…' }, + ], + }); }); it('drops invalid entries from stored values on read', async () => { @@ -45,7 +51,7 @@ describe('GET /api/prefs', () => { expect(res.status).toBe(200); expect(body.value).toEqual([ { tokenId: 'good1', ticker: 'G1' }, - 'legacy-string-id', + { tokenId: 'legacy-string-id', ticker: 'legacy-s…' }, ]); }); @@ -66,12 +72,15 @@ describe('GET /api/prefs', () => { }); describe('POST /api/prefs', () => { - it('saves an array of tokens', async () => { + it('saves an array of tokens (legacy strings normalized to objects)', async () => { const res = await POST(makeCtx(['tok1', 'tok2'])); const body = await res.json(); expect(res.status).toBe(200); expect(body.ok).toBe(true); - expect(setPref).toHaveBeenCalledWith('ml_favourite_tokens', ['tok1', 'tok2']); + expect(setPref).toHaveBeenCalledWith('ml_favourite_tokens', [ + { tokenId: 'tok1', ticker: 'tok1…' }, + { tokenId: 'tok2', ticker: 'tok2…' }, + ]); }); it('returns 400 when body is not an array', async () => { @@ -107,7 +116,7 @@ describe('POST /api/prefs', () => { }); it('rejects malformed entries with 400', async () => { - for (const bad of [[42], [{ tokenId: 'x' }], [{ ticker: 'NOTOKENID' }], [{ tokenId: 'ok', ticker: '' }], [{ tokenId: 'ok', ticker: 9 }]]) { + for (const bad of [[42], [{ tokenId: 'x' }], [{ ticker: 'NOTOKENID' }], [{ tokenId: 'ok', ticker: '' }], [{ tokenId: 'ok', ticker: 9 }], [{ tokenId: 'ok', ticker: 'AB\u202ECBA' }], [{ tokenId: 'ok', ticker: 'a\u0000b' }]]) { const res = await POST(makeCtx(bad)); expect(res.status).toBe(400); } diff --git a/app/src/pages/api/prefs.ts b/app/src/pages/api/prefs.ts index 19abff6..bc4735e 100644 --- a/app/src/pages/api/prefs.ts +++ b/app/src/pages/api/prefs.ts @@ -10,17 +10,22 @@ function isId(value: unknown): value is string { return typeof value === 'string' && value.length > 0 && value.length <= 128; } +/** Tickers are rendered verbatim — reject control and bidi-override characters. */ +const TICKER_BAD = /[\u0000-\u001F\u007F-\u009F\u200E\u200F\u202A-\u202E\u2066-\u2069]/; + /** - * Validates one favourite entry. Accepts the current `{tokenId, ticker}` - * shape and the legacy plain-string shape; anything else is rejected so + * Validates one favourite entry and normalizes it to the `{tokenId, ticker}` + * object shape. Accepts the current object shape and the legacy plain-string + * shape (ticker unknown → short-id placeholder); 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; +function asFavouriteEntry(value: unknown): { tokenId: string; ticker: string } | null { + if (isId(value)) return { tokenId: value, ticker: `${value.slice(0, 8)}…` }; 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; + if (TICKER_BAD.test(e.ticker)) return null; return { tokenId: e.tokenId, ticker: e.ticker }; } @@ -30,7 +35,9 @@ export const GET: APIRoute = () => { // 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) + ? stored + .map(asFavouriteEntry) + .filter((e): e is { tokenId: string; ticker: string } => e !== null) : []; return json({ ok: true, value }); } catch (err) {