// 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 }, ]; expect( amountLine(transferData(FIVE_THOUSAND_AT_SIX), NOVEL_TOKEN), ).toBe("5000.0000"); }); 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"); }); 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", ); }); 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)", ); }); });