From cc9fdfac9955ff4508e564e7c2215dae1805eda7 Mon Sep 17 00:00:00 2001 From: sneak Date: Tue, 6 Oct 2026 20:41:52 +0000 Subject: [PATCH] fix: a token symbol read off a contract is never stored cut mid-emoji (closes #458) lookupTokenInfo() cut the symbol at 12 and the name at 64 UTF-16 units, so an emoji outside the Basic Multilingual Plane could be cut between its two halves and the half left over stored and shown as U+FFFD. Both are now cut on code points, as displaySymbol() does. A symbol already stored broken is not repaired. Model: opus-5-5 --- TODO.md | 9 ++++++ src/shared/balances.js | 9 ++++-- tests/tokenLookupTruncation.test.js | 48 +++++++++++++++++++++++++++++ 3 files changed, 63 insertions(+), 3 deletions(-) create mode 100644 tests/tokenLookupTruncation.test.js diff --git a/TODO.md b/TODO.md index 6116cbf..225c833 100644 --- a/TODO.md +++ b/TODO.md @@ -45,6 +45,15 @@ but the review is broader than any of them. # Completed Steps +- 2026-10-06: A token symbol or name read off a contract is no longer stored cut + between the two halves of an emoji + ([#458](https://git.eeqj.de/sneak/AutistMask/issues/458)). `lookupTokenInfo()` + cut both by UTF-16 units; it now counts code points, as `displaySymbol()` has + since [#329](https://git.eeqj.de/sneak/AutistMask/issues/329). + `tests/tokenLookupTruncation.test.js` looks up a token whose symbol and name + are made of emoji outside the Basic Multilingual Plane. A symbol already + stored broken is not repaired. + - 2026-10-06: Reloading or closing the popup no longer logs a request it cancels as a failure in the transaction lists and ENS name lookups on the address and token screens, the address scan after a wallet is created, the endpoint checks diff --git a/src/shared/balances.js b/src/shared/balances.js index 6b294ba..7c16604 100644 --- a/src/shared/balances.js +++ b/src/shared/balances.js @@ -334,9 +334,12 @@ async function lookupTokenInfo(contractAddress, rpcUrl, networkId, signal) { name = symbol; } - // Truncate to prevent storage of excessively long values from RPC - name = String(name).slice(0, 64); - symbol = String(symbol).slice(0, 12); + // Truncate to prevent storage of excessively long values from RPC. + // Counted in code points, as displaySymbol() counts them, so the cut never + // falls between the two halves of an emoji and stores a half that renders + // as U+FFFD. + name = Array.from(String(name)).slice(0, 64).join(""); + symbol = Array.from(String(symbol)).slice(0, 12).join(""); log.infof("Token resolved:", symbol, "decimals", Number(decimals)); return { name, symbol, decimals: Number(decimals) }; diff --git a/tests/tokenLookupTruncation.test.js b/tests/tokenLookupTruncation.test.js new file mode 100644 index 0000000..e745512 --- /dev/null +++ b/tests/tokenLookupTruncation.test.js @@ -0,0 +1,48 @@ +// lookupTokenInfo() cuts the symbol and name it reads off a contract on +// code-point boundaries, as displaySymbol() counts them, so an emoji outside +// the Basic Multilingual Plane is never stored cut in half +// (https://git.eeqj.de/sneak/AutistMask/issues/458). + +// An emoji outside the Basic Multilingual Plane: two UTF-16 units. +const FOX = "\u{1F98A}"; + +// The leading "A" puts every emoji at an odd UTF-16 offset, so a cut that +// counts UTF-16 units lands between the two halves of one. +const SYMBOL = "A" + FOX.repeat(12); +const NAME = "A" + FOX.repeat(64); + +// The contract answers symbol(), decimals() and name() with the values above. +jest.doMock("ethers", () => ({ + ...jest.requireActual("ethers"), + Contract: function () { + return { + symbol: async () => SYMBOL, + decimals: async () => 18n, + name: async () => NAME, + }; + }, +})); + +const { lookupTokenInfo } = require("../src/shared/balances"); + +const ADDRESS = "0x1111111111111111111111111111111111111111"; +const RPC_URL = "https://rpc.example.invalid"; + +beforeEach(() => { + // A token found is logged at info level, which is not under test. + jest.spyOn(console, "log").mockImplementation(() => {}); +}); + +afterEach(() => { + jest.restoreAllMocks(); +}); + +test("the symbol is cut to 12 code points, never inside an emoji", async () => { + const { symbol } = await lookupTokenInfo(ADDRESS, RPC_URL, "mainnet"); + expect(symbol).toBe("A" + FOX.repeat(11)); +}); + +test("the name is cut to 64 code points, never inside an emoji", async () => { + const { name } = await lookupTokenInfo(ADDRESS, RPC_URL, "mainnet"); + expect(name).toBe("A" + FOX.repeat(63)); +});