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>
211 lines
7.6 KiB
JavaScript
211 lines
7.6 KiB
JavaScript
// The quantity the dApp approval screen shows for a decoded ERC-20 call.
|
|
//
|
|
// The screen's amount line is the only place a user sees how much a page is
|
|
// asking for, and it is decoded from calldata, which carries base units and
|
|
// no scale. Issue #306: decodeCalldata read decimals from the bundled token
|
|
// list alone and fell back to 18, so a `transfer` of 5000000000 units of a
|
|
// 6-decimal token — 5,000 tokens — was displayed as `0.0000` and confirmed.
|
|
//
|
|
// What is asserted here is that the scale is found wherever the wallet
|
|
// already has it, and that where it is nowhere at all no formatted number is
|
|
// produced: the amount line has to say base units and say the scale is
|
|
// unknown, because a wrong quantity that reads as zero is worse than an
|
|
// unwieldy correct one.
|
|
|
|
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 {
|
|
resolveTokenDecimals,
|
|
unknownDecimalsAmount,
|
|
} = 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, at 6 decimals.
|
|
const USDC = "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48";
|
|
const RECIPIENT = "0xC0FfEE0000000000000000000000000000c0fFEe";
|
|
const SPENDER = "0x1111111111111111111111111111111111111111";
|
|
|
|
// 5,000 units of a 6-decimal token, the amount from the issue.
|
|
const FIVE_THOUSAND_AT_SIX = 5000000000n;
|
|
const MAX_UINT256 = (1n << 256n) - 1n;
|
|
|
|
function transferData(amount) {
|
|
return iface.encodeFunctionData("transfer", [RECIPIENT, amount]);
|
|
}
|
|
|
|
function approveData(amount) {
|
|
return iface.encodeFunctionData("approve", [SPENDER, amount]);
|
|
}
|
|
|
|
// The Amount line as the approval screen renders it.
|
|
function amountLine(data, tokenAddress) {
|
|
const decoded = decodeCalldata(data, tokenAddress);
|
|
const detail = decoded.details.find((d) => d.label === "Amount");
|
|
return detail.value;
|
|
}
|
|
|
|
// A wallet holding `token` with the decimals the block explorer reported,
|
|
// shaped as balances.js writes it onto state.
|
|
function walletsHolding(token, decimals) {
|
|
return [
|
|
{
|
|
name: "Wallet 1",
|
|
addresses: [
|
|
{
|
|
address: "0x" + "a".repeat(40),
|
|
balance: "1.0",
|
|
tokenBalances: [
|
|
{
|
|
address: token,
|
|
symbol: "NOVEL",
|
|
decimals,
|
|
balance: "5000.0",
|
|
},
|
|
],
|
|
},
|
|
],
|
|
},
|
|
];
|
|
}
|
|
|
|
beforeEach(() => {
|
|
state.trackedTokens = [];
|
|
state.wallets = [];
|
|
});
|
|
|
|
describe("resolveTokenDecimals", () => {
|
|
test("prefers the bundled list", () => {
|
|
state.trackedTokens = [{ address: USDC, symbol: "USDC", decimals: 2 }];
|
|
expect(resolveTokenDecimals(USDC, state)).toBe(6);
|
|
});
|
|
|
|
test("reads a token the user tracks", () => {
|
|
state.trackedTokens = [
|
|
{
|
|
address: NOVEL_TOKEN.toLowerCase(),
|
|
symbol: "NOVEL",
|
|
decimals: 6,
|
|
},
|
|
];
|
|
expect(resolveTokenDecimals(NOVEL_TOKEN, state)).toBe(6);
|
|
});
|
|
|
|
test("reads the decimals the explorer reported", () => {
|
|
// Blockscout's copy arrives as a string.
|
|
state.wallets = walletsHolding(NOVEL_TOKEN, "6");
|
|
expect(resolveTokenDecimals(NOVEL_TOKEN, state)).toBe(6);
|
|
});
|
|
|
|
test("falls past a tracked entry whose decimals are unusable", () => {
|
|
state.trackedTokens = [
|
|
{ address: NOVEL_TOKEN, symbol: "NOVEL", decimals: NaN },
|
|
];
|
|
state.wallets = walletsHolding(NOVEL_TOKEN, 6);
|
|
expect(resolveTokenDecimals(NOVEL_TOKEN, state)).toBe(6);
|
|
});
|
|
|
|
test("refuses a scale the explorer's own entries disagree about", () => {
|
|
const wallets = walletsHolding(NOVEL_TOKEN, 6);
|
|
wallets[0].addresses.push({
|
|
address: "0x" + "b".repeat(40),
|
|
balance: "0.0",
|
|
tokenBalances: [
|
|
{ address: NOVEL_TOKEN, symbol: "NOVEL", decimals: 18 },
|
|
],
|
|
});
|
|
state.wallets = wallets;
|
|
expect(resolveTokenDecimals(NOVEL_TOKEN, state)).toBeNull();
|
|
});
|
|
|
|
test("rejects values that are not a uint8", () => {
|
|
for (const decimals of [-1, 256, 1.5, true, [], {}, null, "6.0", ""]) {
|
|
state.trackedTokens = [{ address: NOVEL_TOKEN, decimals }];
|
|
expect(resolveTokenDecimals(NOVEL_TOKEN, state)).toBeNull();
|
|
}
|
|
});
|
|
|
|
test("is null when nothing knows the token", () => {
|
|
expect(resolveTokenDecimals(NOVEL_TOKEN, state)).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe("decodeCalldata amount", () => {
|
|
test("transfer of a tracked 6-decimal token shows the true quantity", () => {
|
|
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 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 NOVEL");
|
|
});
|
|
|
|
test("transfer of an unknown-decimals token shows base units, not a number", () => {
|
|
const line = amountLine(
|
|
transferData(FIVE_THOUSAND_AT_SIX),
|
|
NOVEL_TOKEN,
|
|
);
|
|
expect(line).toBe("5000000000 base units (decimals unknown)");
|
|
expect(line).toBe(unknownDecimalsAmount(FIVE_THOUSAND_AT_SIX));
|
|
// The defect: any rendering that reads as a token quantity, and above
|
|
// all one that reads as zero.
|
|
expect(line).not.toMatch(/0\.0000/);
|
|
});
|
|
|
|
test("approve of a tracked 6-decimal token shows the true quantity", () => {
|
|
state.trackedTokens = [
|
|
{ address: NOVEL_TOKEN, symbol: "NOVEL", decimals: 6 },
|
|
];
|
|
expect(amountLine(approveData(FIVE_THOUSAND_AT_SIX), NOVEL_TOKEN)).toBe(
|
|
"5000.0000 NOVEL",
|
|
);
|
|
});
|
|
|
|
test("approve of an unknown-decimals token shows base units, not a number", () => {
|
|
const line = amountLine(approveData(FIVE_THOUSAND_AT_SIX), NOVEL_TOKEN);
|
|
expect(line).toBe("5000000000 base units (decimals unknown)");
|
|
expect(line).not.toMatch(/0\.0000/);
|
|
});
|
|
|
|
test("an unbounded allowance is still named, with or without a scale", () => {
|
|
expect(amountLine(approveData(MAX_UINT256), NOVEL_TOKEN)).toBe(
|
|
"Unlimited",
|
|
);
|
|
expect(amountLine(approveData(MAX_UINT256), USDC)).toBe("Unlimited");
|
|
});
|
|
|
|
test("a bundled token keeps its symbol and its scale", () => {
|
|
expect(amountLine(transferData(FIVE_THOUSAND_AT_SIX), USDC)).toBe(
|
|
"5000.0000 USDC",
|
|
);
|
|
});
|
|
|
|
test("the amount carried to the status screens is the same string", () => {
|
|
const decoded = decodeCalldata(
|
|
transferData(FIVE_THOUSAND_AT_SIX),
|
|
NOVEL_TOKEN,
|
|
);
|
|
const detail = decoded.details.find((d) => d.label === "Amount");
|
|
expect(detail.rawValue).toBe(
|
|
"5000000000 base units (decimals unknown)",
|
|
);
|
|
});
|
|
});
|