// The symbol the dApp approval and status screens label a token with. // // Issue #323: the approval screen labelled anything outside the bundled list // `Unknown token`, even a token the user tracks or holds a balance of, while // the amount line already read that token's *scale* from those same sources // (issue #306). The name and the scale disagreed about which sources they // trust. resolveTokenSymbol() closes that gap: it draws the symbol from the // bundled list, then the tracked tokens, then the explorer's report — the // precedence resolveTokenDecimals() uses — and returns null, not a guess, // when nothing names it, so the screens keep saying `Unknown token`. // // A tracked or explorer-reported symbol is attacker-influenced text, so it // stays subject to the spoof rule (src/shared/symbolSpoof.js): resolving a // symbol must not become a new way for a stray contract to wear a bundled or // native ticker. globalThis.chrome = { storage: { local: { get: async () => ({}), set: async () => {} } }, }; const { Interface } = require("ethers"); const { ERC20_ABI } = require("../src/shared/constants"); const { state } = require("../src/shared/state"); const { resolveTokenSymbol } = require("../src/shared/approvalAmount"); const { decodeCalldata } = require("../src/popup/views/approval"); const iface = new Interface(ERC20_ABI); // Outside the bundled list, as the great majority of ERC-20s are. const NOVEL_TOKEN = "0xE2E0000000000000000000000000000000000E2e"; // In the bundled list: USDC at 6 decimals, DAI at 18. const USDC = "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48"; const RECIPIENT = "0xC0FfEE0000000000000000000000000000c0fFEe"; const FIVE_THOUSAND_AT_SIX = 5000000000n; function transferData(amount) { return iface.encodeFunctionData("transfer", [RECIPIENT, amount]); } // A wallet whose block-explorer balance for `token` reports `symbol`, shaped // as balances.js writes it onto state. function walletsReporting(token, symbol) { return [ { name: "Wallet 1", addresses: [ { address: "0x" + "a".repeat(40), balance: "1.0", tokenBalances: [ { address: token, symbol, decimals: 6, balance: "5000.0", }, ], }, ], }, ]; } beforeEach(() => { state.trackedTokens = []; state.wallets = []; }); describe("resolveTokenSymbol", () => { test("reads the bundled list", () => { expect(resolveTokenSymbol(USDC, state)).toBe("USDC"); }); test("prefers the bundled list over a tracked entry", () => { state.trackedTokens = [{ address: USDC, symbol: "NOTUSDC" }]; expect(resolveTokenSymbol(USDC, state)).toBe("USDC"); }); test("reads a token the user tracks", () => { state.trackedTokens = [{ address: NOVEL_TOKEN, symbol: "NOVEL" }]; expect(resolveTokenSymbol(NOVEL_TOKEN, state)).toBe("NOVEL"); }); test("reads the symbol the explorer reported", () => { state.wallets = walletsReporting(NOVEL_TOKEN, "NOVEL"); expect(resolveTokenSymbol(NOVEL_TOKEN, state)).toBe("NOVEL"); }); test("is null when no source names the token", () => { expect(resolveTokenSymbol(NOVEL_TOKEN, state)).toBeNull(); }); test("refuses a name the explorer's own entries disagree about", () => { const wallets = walletsReporting(NOVEL_TOKEN, "NOVEL"); wallets[0].addresses.push({ address: "0x" + "b".repeat(40), balance: "0.0", tokenBalances: [{ address: NOVEL_TOKEN, symbol: "OTHER" }], }); state.wallets = wallets; expect(resolveTokenSymbol(NOVEL_TOKEN, state)).toBeNull(); }); test("rejects a tracked entry claiming a bundled ticker it is not", () => { // NOVEL_TOKEN is not the real USDC contract, so it may not wear USDC. state.trackedTokens = [{ address: NOVEL_TOKEN, symbol: "USDC" }]; expect(resolveTokenSymbol(NOVEL_TOKEN, state)).toBeNull(); }); test("rejects an explorer entry claiming the native ETH ticker", () => { state.wallets = walletsReporting(NOVEL_TOKEN, "ETH"); expect(resolveTokenSymbol(NOVEL_TOKEN, state)).toBeNull(); }); }); describe("decodeCalldata symbol", () => { test("a tracked token is named, not called Unknown", () => { state.trackedTokens = [ { address: NOVEL_TOKEN, symbol: "NOVEL", decimals: 6 }, ]; const decoded = decodeCalldata( transferData(FIVE_THOUSAND_AT_SIX), NOVEL_TOKEN, ); expect(decoded.description).toBe("Transfer NOVEL"); const amount = decoded.details.find((d) => d.label === "Amount"); expect(amount.value).toBe("5000.0000 NOVEL"); }); test("a token nothing knows keeps a symbol-less label", () => { const decoded = decodeCalldata( transferData(FIVE_THOUSAND_AT_SIX), NOVEL_TOKEN, ); expect(decoded.description).toBe("Transfer ERC-20 token"); const token = decoded.details.find((d) => d.label === "Token"); // The Token line carries the address and is flagged for the screen's // symbol lookup, which resolves to nothing here — so `Unknown token`. expect(token.isToken).toBe(true); expect(token.address).toBe(NOVEL_TOKEN); expect(resolveTokenSymbol(token.address, state)).toBeNull(); }); test("a tracked token spoofing a bundled ticker is not named by it", () => { state.trackedTokens = [ { address: NOVEL_TOKEN, symbol: "USDC", decimals: 6 }, ]; const decoded = decodeCalldata( transferData(FIVE_THOUSAND_AT_SIX), NOVEL_TOKEN, ); expect(decoded.description).toBe("Transfer ERC-20 token"); const amount = decoded.details.find((d) => d.label === "Amount"); expect(amount.value).not.toMatch(/USDC/); }); });