fix: name a tracked or explorer-known token instead of "Unknown token" (closes #323)
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
This commit is contained in:
@@ -143,16 +143,18 @@ describe("decodeCalldata amount", () => {
|
||||
state.trackedTokens = [
|
||||
{ address: NOVEL_TOKEN, symbol: "NOVEL", decimals: 6 },
|
||||
];
|
||||
// The tracked entry supplies both: the scale (5000.0000) and, since
|
||||
// issue #323, the symbol that the scale is counted in.
|
||||
expect(
|
||||
amountLine(transferData(FIVE_THOUSAND_AT_SIX), NOVEL_TOKEN),
|
||||
).toBe("5000.0000");
|
||||
).toBe("5000.0000 NOVEL");
|
||||
});
|
||||
|
||||
test("transfer priced off the explorer's decimals shows the true quantity", () => {
|
||||
state.wallets = walletsHolding(NOVEL_TOKEN, "6");
|
||||
expect(
|
||||
amountLine(transferData(FIVE_THOUSAND_AT_SIX), NOVEL_TOKEN),
|
||||
).toBe("5000.0000");
|
||||
).toBe("5000.0000 NOVEL");
|
||||
});
|
||||
|
||||
test("transfer of an unknown-decimals token shows base units, not a number", () => {
|
||||
@@ -172,7 +174,7 @@ describe("decodeCalldata amount", () => {
|
||||
{ address: NOVEL_TOKEN, symbol: "NOVEL", decimals: 6 },
|
||||
];
|
||||
expect(amountLine(approveData(FIVE_THOUSAND_AT_SIX), NOVEL_TOKEN)).toBe(
|
||||
"5000.0000",
|
||||
"5000.0000 NOVEL",
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -253,8 +253,9 @@ describe("the ERC-20 approval line reaches its refusal", () => {
|
||||
|
||||
test("a scale the explorer did report still formats", async () => {
|
||||
await fetchOnto([row({ decimals: "6" })]);
|
||||
// The same explorer entry now also names the token (issue #323).
|
||||
expect(erc20AmountLine(transferData(THOUSAND_AT_SIX), NOVEL)).toBe(
|
||||
"1000.0000",
|
||||
"1000.0000 NOVEL",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -273,7 +274,7 @@ describe("the swap approval line reaches its refusal", () => {
|
||||
await fetchOnto([row({ decimals: "6" })]);
|
||||
expect(
|
||||
swapAmountLine(swapData(NOVEL, THOUSAND_AT_SIX, WETH, HALF_WETH)),
|
||||
).toBe("1000.0000");
|
||||
).toBe("1000.0000 NOVEL");
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
// 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/);
|
||||
});
|
||||
});
|
||||
@@ -78,15 +78,20 @@ describe("a swap to a token absent from the bundled list", () => {
|
||||
);
|
||||
});
|
||||
|
||||
test("names the address when the scale is known but the symbol is not", () => {
|
||||
test("names the tracked symbol alongside the address (issue #323)", () => {
|
||||
// The tracked entry supplies both halves now: the scale, and the
|
||||
// symbol the output line is named by. Before #323 the symbol was read
|
||||
// from the bundled list alone, so this line fell back to the address.
|
||||
const sources = {
|
||||
trackedTokens: [
|
||||
{ address: NOVEL_OUT, symbol: "NOVEL", decimals: 6 },
|
||||
],
|
||||
};
|
||||
expect(detail(data(), "Token Out", sources).value).toBe(NOVEL_OUT);
|
||||
expect(detail(data(), "Token Out", sources).value).toBe(
|
||||
"NOVEL (" + NOVEL_OUT + ")",
|
||||
);
|
||||
expect(detail(data(), "Min. received", sources).value).toBe(
|
||||
"1000.0000",
|
||||
"1000.0000 NOVEL",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -98,12 +98,13 @@ describe("a swap of a token outside the bundled list", () => {
|
||||
state.trackedTokens = [
|
||||
{ address: NOVEL, symbol: "NOVEL", decimals: 6 },
|
||||
];
|
||||
expect(swapDetail(data(), "Amount").value).toBe("1000.0000");
|
||||
// The tracked entry names the token as well as scaling it (issue #323).
|
||||
expect(swapDetail(data(), "Amount").value).toBe("1000.0000 NOVEL");
|
||||
});
|
||||
|
||||
test("shows the true quantity from the explorer's decimals", () => {
|
||||
state.wallets = walletsHolding(NOVEL, "6");
|
||||
expect(swapDetail(data(), "Amount").value).toBe("1000.0000");
|
||||
expect(swapDetail(data(), "Amount").value).toBe("1000.0000 NOVEL");
|
||||
});
|
||||
|
||||
test("refuses to format when nothing knows the scale", () => {
|
||||
@@ -140,7 +141,7 @@ describe("the Min. received line takes the same rule", () => {
|
||||
{ address: NOVEL_OUT, symbol: "NOVEL", decimals: 6 },
|
||||
];
|
||||
const data = swapData(WETH, HALF_WETH, NOVEL_OUT, THOUSAND_AT_SIX);
|
||||
expect(swapDetail(data, "Min. received").value).toBe("1000.0000");
|
||||
expect(swapDetail(data, "Min. received").value).toBe("1000.0000 NOVEL");
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user