// What the balance fetcher stores when the block explorer reports no decimals // for a token, and what the approval screens then display. // // https://git.eeqj.de/sneak/AutistMask/issues/349: `fetchTokenBalances()` did // `parseInt(item.token.decimals || "18", 10)` BEFORE writing the row, so a // token whose `decimals()` reverts — and which the explorer therefore reports // no scale for — was stored with a fabricated 18. Nothing downstream could // tell that from a real 18. // // That matters because it is upstream of two refusals that were already built // and already merged. https://git.eeqj.de/sneak/AutistMask/issues/306 made the // ERC-20 amount line resolve the real scale or refuse to format, and // https://git.eeqj.de/sneak/AutistMask/issues/340 did the same for the swap // lines. Both read this stored value as an authoritative source, so the guess // walked straight past them: the refusal was intact and simply never fired. // // So these tests run a real explorer response through the real fetcher and // assert on the real approval screens. A test that hand-writes `decimals: null` // onto state would pass on the broken build, because the fabrication is in the // writer, not the readers. jest.mock("../src/shared/log", () => ({ log: { debugf: () => {}, infof: () => {}, warnf: () => {}, errorf: () => {}, }, debugFetch: jest.fn(), setRuntimeDebug: () => {}, isDebug: () => false, })); global.fetch = jest.fn(() => { throw new Error("tests must not perform network requests"); }); const { makeStorageStub } = require("./support/storageStub"); global.chrome = { storage: makeStorageStub() }; const { AbiCoder, Interface } = require("ethers"); const { ERC20_ABI } = require("../src/shared/constants"); const { fetchTokenBalances } = require("../src/shared/balances"); const { debugFetch } = require("../src/shared/log"); const { state } = require("../src/shared/state"); const { unknownDecimalsAmount } = require("../src/shared/approvalAmount"); const { decodeCalldata } = require("../src/popup/views/approval"); const { TOKEN_BY_ADDRESS } = require("../src/shared/tokenList"); const HOLDER = "0x" + "a".repeat(40); const BLOCKSCOUT = "https://blockscout.example/api/v2"; const ROUTER = "0x66a9893cc07d91d95644aedd05d03f95e1dba8af"; const RECIPIENT = "0xC0FfEE0000000000000000000000000000c0fFEe"; const SPENDER = "0x1111111111111111111111111111111111111111"; // Outside the bundled list and untracked, so the explorer is the only source // of a scale for it — which is the case the fabrication was hiding. const NOVEL = "0xE2E0000000000000000000000000000000000E2e"; // In the bundled list, at 18 decimals, for the other side of a swap. const WETH = "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2"; // The holding the explorer reports, in base units. Large enough that it does // not round to zero even when divided by 10^18, which is what makes it the // case the laundering actually REACHED: a smaller holding formatted at the // fabricated 18 comes out "0.0", the balance list drops the row as dust, and // the approval screens then find no source for the scale and refuse anyway — // for the wrong reason, and only by luck. const HOLDING = 5000000000000000000n; // The amount in the dApp's calldata, which is a separate number from the // holding. 1,000.00 of a 6-decimal token; formatted at the fabricated 18 it // reads 0.000000001, and at a real scale of 0 it reads 1000000000. const THOUSAND_AT_SIX = 1000000000n; const HALF_WETH = 500000000000000000n; const erc20Iface = new Interface(ERC20_ABI); const coder = AbiCoder.defaultAbiCoder(); const routerIface = new Interface([ "function execute(bytes commands, bytes[] inputs, uint256 deadline)", ]); // One Blockscout token-balances row. `token` is spread last so a test can // override or blank a field; the base row carries no `decimals` at all, which // is exactly what a token whose decimals() reverts produces. function row(token = {}, value = HOLDING) { return { value: String(value), token: { type: "ERC-20", address_hash: NOVEL, symbol: "NOVEL", name: "Novel Token", // Well clear of the balance list's own spam floor, so the row is // admitted on its holder count alone: neither the bundled list nor // a tracked entry can supply a scale for it. holders_count: "50000", ...token, }, }; } function respondWith(items) { debugFetch.mockImplementation(async () => ({ ok: true, status: 200, statusText: "OK", json: async () => items, })); } // Fetch and place the result exactly where refreshBalances() places it, so the // approval screens read what a real refresh would have left on state. async function fetchOnto(items, trackedTokens = []) { respondWith(items); const balances = await fetchTokenBalances( HOLDER, BLOCKSCOUT, trackedTokens, ); state.trackedTokens = trackedTokens; state.wallets = [ { name: "Wallet 1", addresses: [ { address: HOLDER, balance: "1.0", tokenBalances: balances }, ], }, ]; return balances; } // The ERC-20 approval screen's Amount line, and the swap decoder's. function erc20AmountLine(data, tokenAddress) { return decodeCalldata(data, tokenAddress).details.find( (d) => d.label === "Amount", ).value; } function swapAmountLine(data) { return decodeCalldata(data, ROUTER).details.find( (d) => d.label === "Amount", ).value; } function transferData(amount) { return erc20Iface.encodeFunctionData("transfer", [RECIPIENT, amount]); } function approveData(amount) { return erc20Iface.encodeFunctionData("approve", [SPENDER, amount]); } function swapData(tokenIn, amountIn, tokenOut, amountOutMin) { const input = coder.encode( ["address", "uint256", "uint256", "address[]", "bool"], [RECIPIENT, amountIn, amountOutMin, [tokenIn, tokenOut], true], ); return routerIface.encodeFunctionData("execute", [ "0x08", [input], 9999999999n, ]); } beforeEach(() => { debugFetch.mockReset(); state.trackedTokens = []; state.wallets = []; }); describe("what fetchTokenBalances stores for an absent scale", () => { test("the token is not in the bundled list, so the explorer is the only source", () => { expect(TOKEN_BY_ADDRESS.has(NOVEL.toLowerCase())).toBe(false); }); test("an absent decimals is stored as null, not as 18", async () => { const balances = await fetchOnto([row()]); expect(balances).toHaveLength(1); expect(balances[0].decimals).toBeNull(); }); test("an explicit null decimals is stored as null too", async () => { const balances = await fetchOnto([row({ decimals: null })]); expect(balances[0].decimals).toBeNull(); }); // The same explorer row twice, differing only in whether it reports a // scale of 18. Before the fix both stored 18 and no reader could tell // which one had actually been reported. test("a real 18 is stored as 18, and so is distinguishable from absent", async () => { const real = await fetchOnto([row({ decimals: "18" })]); expect(real[0].decimals).toBe(18); expect(real[0].balance).toBe("5.0"); const absent = await fetchOnto([row()]); expect(absent[0].decimals).toBeNull(); expect(real[0].decimals).not.toBe(absent[0].decimals); }); // The falsy-collapse trap of // https://git.eeqj.de/sneak/AutistMask/issues/246. `decimals || "18"` reads // a real scale of zero as absent and then as eighteen, which is eighteen // orders of magnitude of error in the direction that displays as nothing. test("a real scale of zero is stored as zero, not collapsed", async () => { for (const reported of ["0", 0]) { const balances = await fetchOnto([row({ decimals: reported })]); expect(balances[0].decimals).toBe(0); expect(balances[0].balance).toBe("5000000000000000000.0"); } }); test("no quantity is stated for a holding whose scale is unknown", async () => { const balances = await fetchOnto([row()]); // Not "0.0": the holding is real and nonzero, and a zero here is the // same lie the approval screens refuse to tell. expect(balances[0].balance).toBeNull(); }); // Zero base units is zero tokens at every scale, so this filter never // needed a scale in the first place and does not acquire one now. test("a holding of zero base units is still dropped without a scale", async () => { expect(await fetchOnto([row({}, 0n)])).toEqual([]); }); test("the bundled list still supplies a quantity the explorer omitted", async () => { const balances = await fetchOnto([ row({ address_hash: WETH, symbol: "WETH" }), ]); // The stored decimals stay the explorer's own answer — absent. Copying // another source in here would make explorerDecimals()'s disagreement // check compare something other than explorer values. expect(balances[0].decimals).toBeNull(); // The displayed quantity still comes out right, because the bundled // list knows this token's scale and outranks the explorer anyway. expect(balances[0].balance).toBe("5.0"); }); }); describe("the ERC-20 approval line reaches its refusal", () => { test("a transfer of a token the explorer gave no scale for is not formatted", async () => { await fetchOnto([row()]); const line = erc20AmountLine(transferData(THOUSAND_AT_SIX), NOVEL); expect(line).toBe(unknownDecimalsAmount(THOUSAND_AT_SIX)); // The defect: a fabricated 18 renders this as 0.000000001, a quantity, // and a wrong one. expect(line).not.toMatch(/^0\./); }); test("an approve of the same token is not formatted either", async () => { await fetchOnto([row()]); const line = erc20AmountLine(approveData(THOUSAND_AT_SIX), NOVEL); expect(line).toBe(unknownDecimalsAmount(THOUSAND_AT_SIX)); expect(line).not.toMatch(/^0\./); }); test("a scale the explorer did report still formats", async () => { await fetchOnto([row({ decimals: "6" })]); expect(erc20AmountLine(transferData(THOUSAND_AT_SIX), NOVEL)).toBe( "1000.0000", ); }); }); describe("the swap approval line reaches its refusal", () => { test("a swap of a token the explorer gave no scale for is not formatted", async () => { await fetchOnto([row()]); const line = swapAmountLine( swapData(NOVEL, THOUSAND_AT_SIX, WETH, HALF_WETH), ); expect(line).toBe(unknownDecimalsAmount(THOUSAND_AT_SIX)); expect(line).not.toMatch(/^0\./); }); test("a scale the explorer did report still formats", async () => { await fetchOnto([row({ decimals: "6" })]); expect( swapAmountLine(swapData(NOVEL, THOUSAND_AT_SIX, WETH, HALF_WETH)), ).toBe("1000.0000"); }); }); test("no test in this file performed a network request", () => { expect(global.fetch).not.toHaveBeenCalled(); });