Files
AutistMask/tests/sendDisplayFloor.test.js
T
sneak e0313de2ef
check / check (push) Waiting to run
e2e / e2e-chrome (push) Waiting to run
e2e / e2e-firefox (push) Waiting to run
fix: Blockscout and Etherscan lookups are off until the user turns them on (closes #668)
Out of the box the wallet sent the user's addresses to Blockscout and each
recipient to Etherscan. Two settings, "Use Blockscout" and "Check recipients on
Etherscan", now gate those requests. Both are off in a new install and offered
unticked on Welcome and in Settings. While one is off, the screens that relied
on it say so in a plain line. Each gated request reads its setting from storage
before it asks and when the answer lands, and drops a late answer. An open popup
takes another page's change and draws its screens again. While "Use Blockscout"
is off, no address keeps an untracked token, and no screen names a token from
Blockscout's report. ENS lookups ask only the RPC endpoint.

Model: opus-5-5
2026-10-26 12:44:24 +00:00

476 lines
16 KiB
JavaScript

// 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, and a token holding cut to zero was dropped, while the approval screen
// showed the same fee through src/shared/amountDisplay.js with the nonzero
// floor. The balances are now stored exactly, every nonzero token holding
// kept, 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,
tokenUnits: 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;
}
// balanceOf(), the one contract call these screens make.
async call() {
return actual.AbiCoder.defaultAbiCoder().encode(
["uint256"],
[mockNode.tokenUnits],
);
}
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,
})),
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");
});
// The approval the background hands the approval screen. Set per test.
let approvalDetails = null;
const { makeStorageStub } = require("./support/storageStub");
global.chrome = {
storage: makeStorageStub({ autistmask: { useBlockscout: true } }),
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() {},
};
}
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,
formatAddressTotal,
getAddressValue,
} = 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 message for an amount the balance does not cover, while it is shown.
function balanceError() {
const box = global.document.getElementById("confirm-balance-error");
return box.style.visibility === "visible" ? box.textContent : "";
}
// The ETH balance the node reports and the token rows the explorer reports,
// fetched and stored exactly where the popup stores them. The node reports the
// token holding the explorer does.
async function refreshWith(balanceWei, tokenItems = []) {
mockNode.balanceWei = balanceWei;
mockNode.tokenUnits =
tokenItems.length > 0 ? BigInt(tokenItems[0].value) : 0n;
mockExplorer.items = tokenItems;
state.wallets = [{ name: "Wallet 1", addresses: [{ address: HOLDER }] }];
state.selectedWallet = 0;
state.selectedAddress = 0;
// Blockscout is asked only while the stored "Use Blockscout" is on. The
// screens save this page's copy of the setting over the stored one.
state.useBlockscout = true;
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",
origin: "https://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("");
expect(balanceError()).toBe("");
await confirmSend("0.0000006", TOKEN);
expect(balanceError()).toContain(
"Your TOK balance does not cover this amount.",
);
});
// 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("");
expect(balanceError()).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.
await refreshWith(10n ** 18n, [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(balanceError()).toContain(
"Your TOK balance does not cover this amount.",
);
});
});
// A token the user does not track, with enough holders to be admitted. The
// balance fetch dropped a holding of it below 0.000001, but the token stays
// selected while its own screen is open: after sending 2 of a 2.0000003
// holding, the user is back on that screen, and Send read the missing row as
// zero.
describe("an untracked token holding below 0.000001 never renders as zero", () => {
const row = () => tokenRow(HALF_MICRO_ETH, { holders_count: "50000" });
test("on the Send screen", async () => {
await refreshWith(10n ** 18n, [row()]);
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, [row()]);
await confirmSend("0.0000005", TOKEN);
expect(text("confirm-balance")).toBe("0.0000005 TOK");
expect(errors()).toBe("");
expect(balanceError()).toBe("");
await confirmSend("0.0000006", TOKEN);
expect(balanceError()).toContain(
"Your TOK balance does not cover this amount.",
);
});
});
// The fetch keeps every holding, so the screens that showed only what it kept
// leave out a holding below 0.000001 themselves, and look as they did.
describe("a token holding below 0.000001 is still not listed", () => {
afterEach(() => {
clearPrices();
});
test("for a token the user does not track", async () => {
prices.ETH = 3000;
await refreshWith(0n, [
tokenRow(HALF_MICRO_ETH, { holders_count: "50000" }),
]);
const addr = state.wallets[0].addresses[0];
expect(balanceLinesForAddress(addr, [], true)).not.toContain(TOKEN);
expect(balanceLinesForAddress(addr, [], false)).not.toContain(TOKEN);
send.renderSendTokenSelect(addr);
const options = global.document.getElementById("send-token").children;
expect(options.map((o) => o.value)).toEqual([]);
// Not an unpriced token in the total, and not funds on the
// remove-address warning.
expect(formatAddressTotal(getAddressValue(addr))).toBe("Total: $0.00");
expect(addressHoldsFunds(addr)).toBe(false);
});
// As a tracked token holding nothing: listed only while zero balances are
// shown.
test("for a tracked token, unless zero balances are shown", async () => {
state.trackedTokens = [
{ address: TOKEN, symbol: "TOK", name: "Token", decimals: 18 },
];
await refreshWith(0n, [tokenRow(HALF_MICRO_ETH)]);
const addr = state.wallets[0].addresses[0];
expect(
balanceLinesForAddress(addr, state.trackedTokens, false),
).not.toContain(TOKEN);
expect(
balanceLinesForAddress(addr, state.trackedTokens, true),
).toContain(`data-token="${TOKEN}"`);
});
});
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);
});
});