// 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 or a fee below 0.000001 rendered as `0.0` on these screens // (https://git.eeqj.de/sneak/AutistMask/issues/343): the stored balance 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. Both now go through that one helper. // // Driven through the real refreshBalances(), Send screen, confirmation screen // and approval screen, with only the node 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 }, }; 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: empty. debugFetch: jest.fn(async () => ({ ok: true, status: 200, json: async () => [], })), 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 send = require("../src/popup/views/send"); const confirmTx = require("../src/popup/views/confirmTx"); const approval = require("../src/popup/views/approval"); const HOLDER = "0x" + "a".repeat(40); const RECIPIENT = "0xC0FfEE0000000000000000000000000000c0fFEe"; // 0.0000005 ETH. const HALF_MICRO_ETH = 500000000000n; function text(id) { return global.document.getElementById(id).textContent; } // The ETH balance the node reports, fetched and stored exactly where the popup // stores it. async function refreshWith(balanceWei) { mockNode.balanceWei = balanceWei; 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", [], "mainnet", ); } // Press Review on the Send screen for an ETH send, and show the confirmation // screen it leads to with its fee estimate settled. async function confirmEthSend(amount) { let txInfo = null; send.init({ showConfirmTx: (info) => (txInfo = info) }); state.selectedToken = "ETH"; 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 confirmEthSend("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 confirmEthSend("0.1"); expect(text("confirm-balance")).toBe("1.2345 ETH"); }); }); 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 confirmEthSend("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 confirmEthSend("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. describe("the same fee reads the same on the confirmation and approval screens", () => { test.each([ ["below the floor", 1n], ["with more than four decimals", 58823529411n], ])("%s", async (_label, feePerGas) => { mockNode.feeData = { maxFeePerGas: feePerGas, gasPrice: feePerGas }; await refreshWith(10n ** 18n); await confirmEthSend("0.1"); const onConfirm = text("confirm-fee-amount"); await approveTxWithFeePerGas(feePerGas); expect(text("approve-tx-fee")).toBe(onConfirm); expect(onConfirm).toMatch(/[1-9]/); }); });