// The native token's label on the screens that show a native amount. // // src/shared/networks.js gives each network a nativeCurrency, `ETH` on mainnet // and `SepoliaETH` on Sepolia, and nothing read it: every screen wrote a // hardcoded "ETH", so on Sepolia the balance, the value and the fee all read // ETH (https://git.eeqj.de/sneak/AutistMask/issues/372). Each line is asserted // on both networks, through the real Send, confirmation and approval screens, // with only the node and the DOM stubbed. So is that a token cannot pass for // the native token by reporting its label, and that a transaction's figures // carry its own network's label when another network is active. "use strict"; jest.mock("ethers", () => { const actual = jest.requireActual("ethers"); class StubProvider { async lookupAddress() { return null; } // 10 gwei expected, 20 gwei reserved per gas. async getFeeData() { return { maxFeePerGas: 20000000000n, gasPrice: 10000000000n }; } async estimateGas() { return 21000n; } async getCode() { return "0x"; } async getTransactionCount() { return 1; } async getTransactionReceipt() { return { blockNumber: 21000000 }; } } return { ...actual, JsonRpcProvider: StubProvider, Network: { from: () => ({}) }, }; }); jest.mock("../src/shared/log", () => ({ log: { debugf: () => {}, infof: () => {}, warnf: () => {}, errorf: () => {}, }, debugFetch: jest.fn(async () => ({ ok: true, status: 200, json: async () => ({ items: [] }), })), urlOrigin: () => "", setRuntimeDebug: () => {}, isDebug: () => false, })); // Signing a send succeeds without a key, and sending answers with a hash. jest.mock("../src/shared/vault", () => ({ ...jest.requireActual("../src/shared/vault"), decryptWithPassword: async () => "secret", })); jest.mock("../src/shared/wallet", () => ({ ...jest.requireActual("../src/shared/wallet"), getSignerForAddress: () => ({ connect: () => ({ populateTransaction: async (request) => request, sendTransaction: async () => ({ hash: "0x" + "3".repeat(64) }), }), }), })); 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, children: [], addEventListener(name, fn) { handlers.set(name, fn); }, appendChild(child) { this.children.push(child); return child; }, querySelectorAll: () => [], querySelector: () => null, remove() {}, focus() {}, // Views reach for .parentElement to hide whole sections. 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 { state } = require("../src/shared/state"); const { NETWORKS } = require("../src/shared/networks"); const { 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 transactionDetail = require("../src/popup/views/transactionDetail"); const txStatus = require("../src/popup/views/txStatus"); const { balanceLinesForAddress } = require("../src/popup/views/helpers"); const { filterTransactions } = require("../src/shared/transactions"); const { debugFetch } = require("../src/shared/log"); const HOLDER = "0x" + "a".repeat(40); const RECIPIENT = "0xC0FfEE0000000000000000000000000000c0fFEe"; // A token contract that is not in the bundled token list. const TOKEN_CONTRACT = "0xd05339f9ea5ab9d9f03b9d57f671d2abd1f55c82"; function text(id) { return global.document.getElementById(id).textContent; } // Press Review on the Send screen for a native send of `amount`, and show the // confirmation screen it leads to with its fee estimate settled. async function confirmSend(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 sending 0.01 of the native token // with 21000 gas at up to 20 gwei, on the network with `chainId`. async function approveTx(chainId) { approvalDetails = { type: "tx", origin: "https://dapp.example", approvedFrom: HOLDER, approvedTx: { to: RECIPIENT, value: "10000000000000000", data: "0x", chainId, gasLimit: "21000", maxFeePerGas: "20000000000", nonce: 0, }, }; await approval.show("1"); } describe.each([ ["mainnet", "ETH"], ["sepolia", "SepoliaETH"], ])("on %s the native token reads %s", (networkId, symbol) => { beforeEach(() => { elements.clear(); clearPrices(); state.networkId = networkId; state.wallets = [ { name: "Wallet 1", addresses: [{ address: HOLDER, balance: "1.5" }], }, ]; state.selectedWallet = 0; state.selectedAddress = 0; state.trackedTokens = []; state.fraudContracts = []; state.currentView = null; }); test("the balance", async () => { const addr = state.wallets[0].addresses[0]; expect(balanceLinesForAddress(addr, [], false)).toContain( `${symbol}1.5000`, ); state.selectedToken = "ETH"; send.updateSendBalance(); expect(text("send-balance")).toBe("Current balance: 1.5000 " + symbol); await confirmSend("0.1"); expect(text("confirm-balance")).toBe("1.5000 " + symbol); }); test("the value", async () => { await confirmSend("0.1"); expect(text("confirm-type")).toBe("Native " + symbol + " transfer"); expect(text("confirm-amount")).toBe("0.1 " + symbol); await approveTx(NETWORKS[networkId].chainId); expect(text("approve-tx-value")).toBe("0.0100 " + symbol); }); test("the fee", async () => { await confirmSend("0.1"); // 21000 gas at 10 gwei expected, at 20 gwei reserved. expect(text("confirm-fee-amount")).toBe("~0.0002 " + symbol); expect(text("confirm-fee-reserve")).toBe( "up to 0.0004 " + symbol + " reserved", ); expect(text("confirm-gas-error")).toContain( "You do not have enough " + symbol + " to pay the network fee", ); await approveTx(NETWORKS[networkId].chainId); expect(text("approve-tx-fee")).toBe("0.0004 " + symbol); }); test("the contract-recipient warning", async () => { await confirmSend("0.1"); expect(text("confirm-contract-warning")).toContain( "Sending " + symbol + " or tokens directly to a contract", ); }); // A token reports whatever symbol it likes. One reporting the label the // wallet shows its native token under, on this network or any other, is // a fake, exactly as one reporting `ETH` always was. test.each(["ETH", symbol])( "a token claiming %s is dropped from the history and the Send selector", (claim) => { const result = filterTransactions( [ { hash: "0x" + "1".repeat(64), symbol: claim, contractAddress: TOKEN_CONTRACT, holders: 900000, valueGwei: null, isContractCall: false, }, ], { hideSpoofedSymbols: true }, ); expect(result.transactions).toEqual([]); expect(result.newFraudContracts).toEqual([TOKEN_CONTRACT]); send.renderSendTokenSelect({ address: HOLDER, tokenBalances: [ { address: TOKEN_CONTRACT, symbol: claim, decimals: 18, balance: "5", holders: 900000, }, ], }); expect( global.document.getElementById("send-token").children, ).toEqual([]); }, ); // The detail screen tells the two apart by the token contract, which only // a token transfer has, so a token reporting the native label still reads // as a token transfer. test("the transaction detail screen's type line", () => { const entry = { hash: "0x" + "2".repeat(64), from: RECIPIENT, to: HOLDER, value: "1.0000", exactValue: "1.0", symbol, timestamp: 1790000000, isError: false, direction: "received", directionLabel: "Received", chainId: NETWORKS[networkId].chainId, }; transactionDetail.show({ ...entry, contractAddress: null }); expect(text("tx-detail-type")).toBe("Native " + symbol + " Transfer"); transactionDetail.show({ ...entry, contractAddress: TOKEN_CONTRACT }); expect(text("tx-detail-type")).toBe("ERC-20 Token Transfer"); }); test("the insufficient-balance error", async () => { await confirmSend("2"); expect( global.document.getElementById("confirm-errors").innerHTML, ).toContain( "You have 1.5000 " + symbol + " but are trying to send 2 " + symbol + ".", ); }); }); // A transaction's value and fee are in the native currency of the network the // transaction is on, which need not be the active one. A site can switch the // active network after its transaction is prepared and back before it is // signed, and a popup opened after a switch shows a sent or listed transaction // again. The wallet's balances follow the active network; these do not. describe.each([ ["mainnet", "sepolia", "ETH"], ["sepolia", "mainnet", "SepoliaETH"], ])( "a %s transaction shown with %s active reads %s", (txNetworkId, activeNetworkId, symbol) => { const chainId = NETWORKS[txNetworkId].chainId; const hash = "0x" + "3".repeat(64); beforeEach(() => { elements.clear(); clearPrices(); state.networkId = activeNetworkId; state.wallets = [ { name: "Wallet 1", addresses: [{ address: HOLDER, balance: "1.5" }], }, ]; state.selectedWallet = 0; state.selectedAddress = 0; state.trackedTokens = []; state.fraudContracts = []; state.currentView = null; txStatus.init({ doRefreshAndRender() {} }); }); afterEach(() => { txStatus.endWait(); }); test("the approval screen's value and fee", async () => { await approveTx(chainId); expect(text("approve-tx-network")).toBe(NETWORKS[txNetworkId].name); expect(text("approve-tx-value")).toBe("0.0100 " + symbol); expect(text("approve-tx-fee")).toBe("0.0004 " + symbol); }); test("the wait, success and error screens", async () => { const txInfo = { from: HOLDER, to: RECIPIENT, amount: "0.0100", token: "ETH", tokenSymbol: null, chainId, }; txStatus.showWait(txInfo, hash); expect(text("wait-tx-summary")).toBe("0.0100 " + symbol); txStatus.showError(txInfo, hash, "Failed."); expect(text("error-tx-summary")).toBe("0.0100 " + symbol); // A later popup resumes the wait, and the receipt is there. state.viewData = { pendingWait: { txInfo, hash, broadcastTime: Date.now() }, }; txStatus.restoreWait(); for (let i = 0; i < 10; i++) { await new Promise((r) => setTimeout(r, 0)); } expect(text("success-tx-summary")).toBe("0.0100 " + symbol); }); // Sent from the Send screen on the transaction's network, then // resumed by a popup that opens after the active network changed. test("the wait screen after a send", async () => { state.networkId = txNetworkId; await confirmSend("0.1"); confirmTx.init({}); global.document.getElementById("confirm-tx-password").value = "pw"; await global.document .getElementById("btn-confirm-send") .handlers.get("click")(); expect(text("wait-tx-summary")).toBe("0.1 " + symbol); state.networkId = activeNetworkId; txStatus.restoreWait(); expect(text("wait-tx-summary")).toBe("0.1 " + symbol); }); test("the transaction detail screen's type line and fee", async () => { debugFetch.mockImplementationOnce(async () => ({ ok: true, status: 200, json: async () => ({ fee: { value: "21000000000000" } }), })); transactionDetail.show({ hash, from: RECIPIENT, to: HOLDER, value: "1.0000", exactValue: "1.0", symbol, timestamp: 1790000000, isError: false, direction: "received", directionLabel: "Received", contractAddress: null, chainId, }); expect(text("tx-detail-type")).toBe( "Native " + symbol + " Transfer", ); for (let i = 0; i < 10; i++) { await new Promise((r) => setTimeout(r, 0)); } expect( global.document.getElementById("tx-detail-fee").innerHTML, ).toContain("0.000021 " + symbol); }); }, );