The approval and transaction-status screens read a token's scale from the bundled list, the tokens the user tracks, then the block explorer, but read its symbol from the bundled list alone. A token the user added by hand was scaled correctly yet labelled "Unknown token", and a non-bundled ERC-20 was carried onto the wait screen as ETH. resolveTokenSymbol() now draws the symbol through the same sources and precedence as the scale, and the ERC-20 and Uniswap swap lines both use it. A tracked or explorer-reported name stays subject to the spoof rule, so it cannot claim a bundled or native ticker. Folds in #354. Model: opus-4-8 Co-authored-by: clawbot <clawbot@noreply.example.org>
157 lines
6.0 KiB
JavaScript
157 lines
6.0 KiB
JavaScript
// 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/);
|
|
});
|
|
});
|