// The amount of the wallet's own send on the confirmation, wait, success and // error screens (https://git.eeqj.de/sneak/AutistMask/issues/522). // // These screens printed the text from Send's amount field as it was typed or // filled in: `0.5 ETH`, or `1.234147890123456789 ETH` after Max, where the // approval screen shows a site's send as `0.5000 ETH`. They now show it to four // decimal places with the nonzero floor, as the approval screen does, and the // transaction is still signed for the exact amount. // // Driven through the real Send screen, confirmation screen, Sign & Send and // wait screen, with only the node, the explorer and the DOM stubbed. "use strict"; // What the stub node answers, and the signed transactions it was handed. The // token's own decimals() answers 6, as the explorer does. const mockNode = { balanceWei: 0n, tokenUnits: 0n, tokenDecimals: 6n, broadcast: [], broadcastError: null, receipt: null, }; // The token rows the stub explorer reports for the address. const mockExplorer = { items: [] }; jest.mock("ethers", () => { const actual = jest.requireActual("ethers"); // The selector of decimals(); every other contract call is balanceOf(). const DECIMALS = "0x313ce567"; class StubProvider { async getBalance() { return mockNode.balanceWei; } async call(tx) { return actual.AbiCoder.defaultAbiCoder().encode( ["uint256"], [ tx.data.startsWith(DECIMALS) ? mockNode.tokenDecimals : mockNode.tokenUnits, ], ); } async lookupAddress() { return null; } // 20 gwei reserved per gas. async getFeeData() { return { maxFeePerGas: 20000000000n, maxPriorityFeePerGas: 1000000000n, gasPrice: 10000000000n, }; } async estimateGas() { return 21000n; } async getCode() { return "0x"; } async getTransactionCount() { return 1; } async getNetwork() { return { chainId: 1n }; } async broadcastTransaction(signed) { if (mockNode.broadcastError) throw mockNode.broadcastError; mockNode.broadcast.push(signed); return { hash: "0x" + "ab".repeat(32) }; } async getTransactionReceipt() { return mockNode.receipt; } } 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, })), urlOrigin: () => "", 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"); }); const { makeStorageStub } = require("./support/storageStub"); global.chrome = { storage: makeStorageStub(), runtime: { sendMessage() {} } }; // 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, children: [], addEventListener(name, fn) { handlers.set(name, fn); }, appendChild(child) { this.children.push(child); return child; }, querySelectorAll: () => [], querySelector: () => null, remove() {}, focus() {}, // The success screen hides whole sections through .parentElement. get parentElement() { return global.document.getElementById(id + "-parent"); }, }; } 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 { Interface, Transaction, Wallet, getAddress, parseEther, } = require("ethers"); const { refreshBalances } = require("../src/shared/balances"); const { encryptWithPassword } = require("../src/shared/vault"); const { state } = require("../src/shared/state"); const { clearPrices } = require("../src/shared/prices"); const send = require("../src/popup/views/send"); const confirmTx = require("../src/popup/views/confirmTx"); const txStatus = require("../src/popup/views/txStatus"); const PRIVATE_KEY = "0x" + "11".repeat(32); const HOLDER = new Wallet(PRIVATE_KEY).address; const RECIPIENT = "0xC0FfEE0000000000000000000000000000c0fFEe"; const PASSWORD = "correct horse battery staple"; const GWEI = 1000000000n; // 1.234567890123456789 ETH. const BALANCE_WEI = 1234567890123456789n; // A token the bundled list does not know, with enough holders to be listed, // at 6 decimal places. const TOKEN = "0x" + "d".repeat(40); const transferIface = new Interface([ "function transfer(address to, uint256 amount)", ]); let encryptedSecret; // The confirmation screen Review leads to, once shown. let confirmed = null; function el(id) { return global.document.getElementById(id); } function text(id) { return el(id).textContent; } // show() reads the balance and then estimates the fee without awaiting // either; this lets both settle. async function settle() { for (let i = 0; i < 10; i++) await new Promise((r) => setTimeout(r, 0)); } // The address holds BALANCE_WEI and, when `tokenUnits` is given, that many // base units of TOKEN, fetched and stored where the popup stores them. Send // is then open for `token` ("ETH" or TOKEN) with the recipient entered. async function openSend(token, tokenUnits = null) { mockNode.balanceWei = BALANCE_WEI; mockNode.tokenUnits = tokenUnits ?? 0n; mockExplorer.items = tokenUnits === null ? [] : [ { value: String(tokenUnits), token: { type: "ERC-20", address_hash: TOKEN, symbol: "TOK", name: "Token", decimals: "6", holders_count: "50000", }, }, ]; state.wallets = [ { type: "key", name: "Wallet 1", encryptedSecret, 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", ); send.init({ showConfirmTx: (info) => (confirmed = info), pageClosed: new AbortController().signal, }); confirmTx.init({}); send.resetSendValidation(); state.currentView = "send"; state.selectedToken = token; el("send-to").value = RECIPIENT; el("send-amount").value = ""; } // Press Review and show the confirmation screen with its fee estimate settled. async function review() { await el("btn-send-review").handlers.get("click")(); confirmTx.show(confirmed); await settle(); } async function signAndSend() { el("confirm-tx-password").value = PASSWORD; await el("btn-confirm-send").handlers.get("click")(); } // The one transaction the stub node was handed. function broadcastTx() { expect(mockNode.broadcast).toHaveLength(1); return Transaction.from(mockNode.broadcast[0]); } beforeAll(async () => { encryptedSecret = await encryptWithPassword(PRIVATE_KEY, PASSWORD); }); beforeEach(() => { elements.clear(); clearPrices(); confirmed = null; state.selectedToken = null; state.trackedTokens = []; state.fraudContracts = []; state.currentView = null; mockNode.broadcast = []; mockNode.broadcastError = null; mockNode.receipt = null; txStatus.init({ doRefreshAndRender() {}, pageClosed: new AbortController().signal, }); }); afterEach(() => { txStatus.endWait(); }); describe("the wallet's own send shows its amount to four decimal places", () => { test("for a short amount", async () => { await openSend("ETH"); el("send-amount").value = "0.5"; await review(); expect(text("confirm-amount")).toBe("0.5000 ETH"); await signAndSend(); expect(text("wait-tx-summary")).toBe("0.5000 ETH"); expect(broadcastTx().value).toBe(parseEther("0.5")); }); // Send accepts both. The approval screen writes the same value `0.5000`. test.each([".5", "00.5"])("for an amount typed as %s", async (typed) => { await openSend("ETH"); el("send-amount").value = typed; await review(); expect(text("confirm-amount")).toBe("0.5000 ETH"); await signAndSend(); expect(text("wait-tx-summary")).toBe("0.5000 ETH"); expect(broadcastTx().value).toBe(parseEther("0.5")); }); test("for a token amount typed as .5", async () => { // 100 TOK. await openSend(TOKEN, 100000000n); el("send-amount").value = ".5"; await review(); expect(text("confirm-amount")).toBe("0.5000 TOK"); await signAndSend(); expect(text("wait-tx-summary")).toBe("0.5000 TOK"); const [, amount] = transferIface.decodeFunctionData( "transfer", broadcastTx().data, ); expect(amount).toBe(500000n); }); test("for a Max amount with many decimals", async () => { await openSend("ETH"); await el("btn-send-max").handlers.get("click")(); // The balance minus 21000 gas at 20 gwei. expect(el("send-amount").value).toBe("1.234147890123456789"); await review(); expect(text("confirm-amount")).toBe("1.2341 ETH"); await signAndSend(); expect(text("wait-tx-summary")).toBe("1.2341 ETH"); expect(broadcastTx().value).toBe(BALANCE_WEI - 21000n * 20n * GWEI); }); // Cut to four decimals it would read 0.0000, so it is shown to its first // significant digit instead, and signed in full. test("for a tiny nonzero amount", async () => { await openSend("ETH"); el("send-amount").value = "0.000012345"; await review(); expect(text("confirm-amount")).toBe("0.00001 ETH"); await signAndSend(); expect(text("wait-tx-summary")).toBe("0.00001 ETH"); expect(broadcastTx().value).toBe(parseEther("0.000012345")); }); test("for a token amount", async () => { // 100 TOK. await openSend(TOKEN, 100000000n); el("send-amount").value = "12.345678"; await review(); expect(text("confirm-amount")).toBe("12.3456 TOK"); await signAndSend(); expect(text("wait-tx-summary")).toBe("12.3456 TOK"); const tx = broadcastTx(); expect(tx.to).toBe(getAddress(TOKEN)); const [to, amount] = transferIface.decodeFunctionData( "transfer", tx.data, ); expect(to).toBe(RECIPIENT); expect(amount).toBe(12345678n); }); test("on the success screen", async () => { await openSend("ETH"); el("send-amount").value = "0.5"; await review(); await signAndSend(); // A popup opened later resumes the wait, and the receipt is there. mockNode.receipt = { blockNumber: 21000000 }; txStatus.restoreWait(); await settle(); expect(state.currentView).toBe("success-tx"); expect(text("success-tx-summary")).toBe("0.5000 ETH"); }); test("on the error screen", async () => { await openSend("ETH"); el("send-amount").value = "0.5"; await review(); mockNode.broadcastError = new Error("the node refused it"); await signAndSend(); expect(state.currentView).toBe("error-tx"); expect(text("error-tx-summary")).toBe("0.5000 ETH"); }); });