bundledToken(address, chainId) in tokenList.js answers only for Ethereum Mainnet's chain id. Every reader of TOKEN_BY_ADDRESS now goes through it, with the chain id of the transaction, balance or history entry it draws. The token sources the screens build carry that chain id. The Uniswap decoder sets the transaction's chain id on them. fetchTokenBalances() takes the chain id of the network it asks about. isSpoofedSymbol() takes the chain id of the token's network. It checks the bundled list's symbols only on Ethereum Mainnet. The native asset's labels stay reserved on every network. Eighteen existing tests and three shared test helpers read the bundled list without naming a network. Each now names Ethereum Mainnet. Model: opus-5-5
171 lines
6.7 KiB
JavaScript
171 lines
6.7 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", () => {
|
|
// The bundled list is read only for a token on Ethereum Mainnet.
|
|
test("reads the bundled list", () => {
|
|
expect(resolveTokenSymbol(USDC, { chainId: "0x1" })).toBe("USDC");
|
|
});
|
|
|
|
test("prefers the bundled list over a tracked entry", () => {
|
|
state.trackedTokens = [{ address: USDC, symbol: "NOTUSDC" }];
|
|
const sources = { chainId: "0x1", trackedTokens: state.trackedTokens };
|
|
expect(resolveTokenSymbol(USDC, sources)).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" }];
|
|
const sources = { chainId: "0x1", trackedTokens: state.trackedTokens };
|
|
expect(resolveTokenSymbol(NOVEL_TOKEN, sources)).toBeNull();
|
|
});
|
|
|
|
// The bundled list holds mainnet contracts, so on Sepolia any contract may
|
|
// carry one of its symbols.
|
|
test("on Sepolia, reads a tracked entry carrying a bundled ticker", () => {
|
|
state.trackedTokens = [{ address: NOVEL_TOKEN, symbol: "USDC" }];
|
|
const sources = {
|
|
chainId: "0xaa36a7",
|
|
trackedTokens: state.trackedTokens,
|
|
};
|
|
expect(resolveTokenSymbol(NOVEL_TOKEN, sources)).toBe("USDC");
|
|
});
|
|
|
|
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/);
|
|
});
|
|
});
|