Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions app/src/components/TokenSearch.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof vi.fn>).mockRejectedValue(new Error('daemon down'));

render(<TokenSearch network="mainnet" />);
expect(await screen.findByText('OLDFAV')).toBeInTheDocument();
expect(screen.queryByText('???')).not.toBeInTheDocument();
});
});
9 changes: 6 additions & 3 deletions app/src/components/TokenSearch.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -526,6 +528,7 @@ export default function TokenSearch({ network }: Props) {
explorerBase={explorerBase}
isFavourite={true}
onToggleFavourite={toggleFavourite}
fallbackTicker={ticker}
/>
);
})}
Expand Down
21 changes: 15 additions & 6 deletions app/src/pages/api/prefs.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand All @@ -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…' },
]);
});

Expand All @@ -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 () => {
Expand Down Expand Up @@ -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);
}
Expand Down
17 changes: 12 additions & 5 deletions app/src/pages/api/prefs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>;
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 };
}

Expand All @@ -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) {
Expand Down
Loading