// The balance and fee lines of the Send and confirmation screens, and the fee // line they must share with the approval screen. // // An ETH balance, a token balance or a fee below 0.000001 rendered as zero on // these screens (https://git.eeqj.de/sneak/AutistMask/issues/343): the stored // balances and the fee were each cut to six decimal places, a rule of their // own, while the approval screen showed the same fee through // src/shared/amountDisplay.js with the nonzero floor. The balances are now // stored exactly, and the screens show them and the fee through that helper. // // Driven through the real refreshBalances(), Send screen, confirmation screen // and approval screen, with only the node, the explorer and the DOM stubbed: a // balance written onto state by hand would skip the place the cut happened. "use strict"; // What the stub node answers. Each test sets what it needs. const mockNode = { balanceWei: 0n, feeData: { maxFeePerGas: 1n, gasPrice: 1n }, }; // The token rows the stub explorer reports for the address. const mockExplorer = { items: [] }; jest.mock("ethers", () => { const actual = jest.requireActual("ethers"); class StubProvider { async getBalance() { return mockNode.balanceWei; } async lookupAddress() { return null; } async getFeeData() { return mockNode.feeData; } async estimateGas() { return 21000n; } async getCode() { return "0x"; } async getTransactionCount() { return 1; } } return { ...actual, JsonRpcProvider: StubProvider, Network: { from: () => ({}) }, }; }); jest.mock("../src/shared/log", () => ({ log: { debugf: () => {}, infof: () => {}, warnf: () => {}, errorf: () => {}, }, // The explorer's token list, which refreshBalances() also fetches. debugFetch: jest.fn(async () => ({ ok: true, status: 200, json: async () => mockExplorer.items, })), setRuntimeDebug: () => {}, isDebug: () => false, })); // The confirmation screen's Etherscan label lookup is the only fetch() these // screens make; it fails, as it does offline. global.fetch = jest.fn(() => { throw new Error("tests must not perform network requests"); }); // The approval the background hands the approval screen. Set per test. let approvalDetails = null; const { makeStorageStub } = require("./support/storageStub"); global.chrome = { storage: makeStorageStub(), runtime: { connect: () => ({ postMessage() {}, disconnect() {}, onDisconnect: { addListener() {} }, }), sendMessage(message, callback) { callback( message.type === "AUTISTMASK_GET_APPROVAL" ? approvalDetails : undefined, ); }, }, }; // A stub DOM: every id resolves to a recording element. const elements = new Map(); function makeEl(id) { const handlers = new Map(); return { id, textContent: "", innerHTML: "", value: "", disabled: false, style: {}, dataset: {}, classList: { add() {}, remove() {}, toggle() {}, contains: () => false, }, handlers, addEventListener(name, fn) { handlers.set(name, fn); }, appendChild(child) { return child; }, querySelectorAll: () => [], querySelector: () => null, remove() {}, focus() {}, }; } global.document = { getElementById(id) { if (!elements.has(id)) elements.set(id, makeEl(id)); return elements.get(id); }, createElement: (tag) => makeEl(tag), body: { prepend() {}, appendChild() {} }, addEventListener() {}, }; global.navigator = { clipboard: { writeText() {} } }; const { refreshBalances } = require("../src/shared/balances"); const { state } = require("../src/shared/state"); const { prices, clearPrices } = require("../src/shared/prices"); const send = require("../src/popup/views/send"); const confirmTx = require("../src/popup/views/confirmTx"); const approval = require("../src/popup/views/approval"); const { addressHoldsFunds, balanceLinesForAddress, } = require("../src/popup/views/helpers"); const HOLDER = "0x" + "a".repeat(40); const RECIPIENT = "0xC0FfEE0000000000000000000000000000c0fFEe"; // 0.0000005 ETH, or 0.0000005 of an 18-decimal token. const HALF_MICRO_ETH = 500000000000n; // A token the bundled list does not know. const TOKEN = "0x" + "d".repeat(40); // The explorer's row for TOKEN, holding `value` base units. With only five // holders, it is listed only when the user tracks the token. function tokenRow(value, token = {}) { return { value: String(value), token: { type: "ERC-20", address_hash: TOKEN, symbol: "TOK", name: "Token", decimals: "18", holders_count: "5", ...token, }, }; } function text(id) { return global.document.getElementById(id).textContent; } function errors() { return global.document.getElementById("confirm-errors").innerHTML; } // The ETH balance the node reports and the token rows the explorer reports, // fetched and stored exactly where the popup stores them. async function refreshWith(balanceWei, tokenItems = []) { mockNode.balanceWei = balanceWei; mockExplorer.items = tokenItems; state.wallets = [{ name: "Wallet 1", addresses: [{ address: HOLDER }] }]; state.selectedWallet = 0; state.selectedAddress = 0; await refreshBalances( state.wallets, "https://rpc.example.invalid", "https://blockscout.example/api/v2", state.trackedTokens, "mainnet", ); } // Press Review on the Send screen for a send of `token` ("ETH" or a token // address), and show the confirmation screen it leads to with its fee estimate // settled. async function confirmSend(amount, token = "ETH") { let txInfo = null; send.init({ showConfirmTx: (info) => (txInfo = info) }); state.selectedToken = token; global.document.getElementById("send-to").value = RECIPIENT; global.document.getElementById("send-amount").value = amount; await global.document .getElementById("btn-send-review") .handlers.get("click")(); confirmTx.show(txInfo); for (let i = 0; i < 10; i++) await new Promise((r) => setTimeout(r, 0)); } // The approval screen for a dApp transaction of 21000 gas, the gas the stub // node estimates for the send above. async function approveTxWithFeePerGas(maxFeePerGas) { approvalDetails = { type: "tx", hostname: "dapp.example", approvedFrom: HOLDER, approvedTx: { to: RECIPIENT, value: "0", data: "0x", chainId: "0x1", gasLimit: "21000", maxFeePerGas: String(maxFeePerGas), nonce: 0, }, }; await approval.show("1"); } beforeEach(() => { elements.clear(); state.selectedToken = null; state.trackedTokens = []; state.fraudContracts = []; state.currentView = null; mockNode.feeData = { maxFeePerGas: 1n, gasPrice: 1n }; }); describe("an ETH balance below 0.000001 never renders as zero", () => { test("on the Send screen", async () => { await refreshWith(HALF_MICRO_ETH); state.selectedToken = "ETH"; send.updateSendBalance(); expect(text("send-balance")).toBe("Current balance: 0.0000005 ETH"); }); test("on the confirmation screen", async () => { await refreshWith(HALF_MICRO_ETH); await confirmSend("0.0000001"); expect(text("confirm-balance")).toBe("0.0000005 ETH"); }); test("while a balance above the floor keeps four decimals", async () => { await refreshWith(1234567890000000000n); await confirmSend("0.1"); expect(text("confirm-balance")).toBe("1.2345 ETH"); }); }); // A token the user tracks stays on the balance list when the explorer's row is // dropped, so a holding of it below 0.000001 reached these screens as zero, and // the send was checked against zero. describe("a tracked token holding below 0.000001 never renders as zero", () => { beforeEach(() => { state.trackedTokens = [ { address: TOKEN, symbol: "TOK", name: "Token", decimals: 18 }, ]; }); test("on the Send screen", async () => { await refreshWith(10n ** 18n, [tokenRow(HALF_MICRO_ETH)]); state.selectedToken = TOKEN; send.updateSendBalance(); expect(text("send-balance")).toBe("Current balance: 0.0000005 TOK"); }); test("on the confirmation screen, which checks the send against it", async () => { await refreshWith(10n ** 18n, [tokenRow(HALF_MICRO_ETH)]); await confirmSend("0.0000005", TOKEN); expect(text("confirm-balance")).toBe("0.0000005 TOK"); expect(errors()).toBe(""); await confirmSend("0.0000006", TOKEN); expect(errors()).toContain( "You have 0.0000005 TOK but are trying to send 0.0000006 TOK.", ); }); // The stored balance keeps all 24 places, and the balance check reads the // first 18 of them rather than refusing it as no balance at all. test("with more than 18 decimals, the send is checked against 18 of them", async () => { state.trackedTokens[0].decimals = 24; // 1.5 plus one base unit. const value = 15n * 10n ** 23n + 1n; await refreshWith(10n ** 18n, [tokenRow(value, { decimals: "24" })]); await confirmSend("1.5", TOKEN); expect(text("confirm-balance")).toBe("1.5000 TOK"); expect(errors()).toBe(""); }); test("with more than 18 decimals and a holding below 10^-18", async () => { state.trackedTokens[0].decimals = 24; // One base unit, 0.000000000000000000000001 TOK, and no ETH, so the // token is all the address holds. await refreshWith(0n, [tokenRow(1n, { decimals: "24" })]); state.selectedToken = TOKEN; send.updateSendBalance(); expect(text("send-balance")).toBe( "Current balance: 0.000000000000000000000001 TOK", ); await confirmSend("0.000000000000000001", TOKEN); expect(text("confirm-balance")).toBe("0.000000000000000000000001 TOK"); expect(errors()).toContain( "You have 0.000000000000000000000001 TOK but are trying to send" + " 0.000000000000000001 TOK.", ); // Listed with zero balances hidden, and counted as funds. const addr = state.wallets[0].addresses[0]; expect( balanceLinesForAddress(addr, state.trackedTokens, false), ).toContain(`data-token="${TOKEN}"`); expect(addressHoldsFunds(addr)).toBe(true); }); test("while an untracked holding below 0.000001 is still not listed", async () => { state.trackedTokens = []; await refreshWith(10n ** 18n, [ tokenRow(HALF_MICRO_ETH, { holders_count: "50000" }), ]); expect(state.wallets[0].addresses[0].tokenBalances).toEqual([]); }); }); describe("a fee below 0.000001 ETH never renders as zero", () => { test("when the estimate and the reserve are the same", async () => { await refreshWith(10n ** 18n); await confirmSend("0.1"); // 21000 gas at 1 wei is 0.000000000000021 ETH, shown to its first // significant digit. expect(text("confirm-fee-amount")).toBe("0.00000000000002 ETH"); }); test("when they differ, on both lines", async () => { mockNode.feeData = { maxFeePerGas: 2n, gasPrice: 1n }; await refreshWith(10n ** 18n); await confirmSend("0.1"); expect(text("confirm-fee-amount")).toBe("~0.00000000000002 ETH"); expect(text("confirm-fee-reserve")).toBe( "up to 0.00000000000004 ETH reserved", ); }); }); // The confirmation screen shows the reserve alone when the node quotes no // cheaper estimate, and that reserve is the same gas limit times maximum fee // per gas that the approval screen calls the max fee. An ETH price is set, as // it is on mainnet, so the USD value has to match too. describe("the same fee reads the same on the confirmation and approval screens", () => { beforeEach(() => { prices.ETH = 3000; }); afterEach(() => { clearPrices(); }); test.each([ // 21000 gas at 1 wei. ["below the floor", 1n, "0.00000000000002 ETH (< $0.01)"], // 0.001235294117631 ETH, which is $3.71. Pricing the truncated // 0.0012 instead would read $3.60. ["with more than four decimals", 58823529411n, "0.0012 ETH ($3.71)"], ])("%s", async (_label, feePerGas, expected) => { mockNode.feeData = { maxFeePerGas: feePerGas, gasPrice: feePerGas }; await refreshWith(10n ** 18n); await confirmSend("0.1"); expect(text("confirm-fee-amount")).toBe(expected); await approveTxWithFeePerGas(feePerGas); expect(text("approve-tx-fee")).toBe(expected); }); });