fix: show balances and fees below 0.000001 ETH as nonzero on the send screens (closes #343)
check / check (push) Successful in 2m53s
e2e / e2e-chrome (push) Successful in 4m35s
e2e / e2e-firefox (push) Successful in 3m46s

The stored ETH balance and the send-confirm screen's fee were each cut to six
decimal places, so a value below 0.000001 read as 0.0, and the fee no longer
matched the approval screen's. The ETH balance is now stored exactly, and the
send screen's Current balance and the send-confirm screen's balance, fee,
reserve and insufficient-balance messages go through truncateAmountNeverZero(),
as the approval screen does. Token balances keep their six-decimal value: it is
also what leaves dust off the balance list, and keeps the string within the 18
decimals the balance check reads.

Model: opus-5-5
This commit is contained in:
2026-10-04 01:22:45 +00:00
parent 6c70a82de8
commit 1df0ebb493
8 changed files with 348 additions and 46 deletions
+273
View File
@@ -0,0 +1,273 @@
// 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]/);
});
});