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
+10 -5
View File
@@ -889,11 +889,16 @@ shows as `0.9999`, never rounded up.
The rule and its exception live in `src/shared/amountDisplay.js` as
`truncateAmount()` and `truncateAmountNeverZero()`. Everything the approval and
confirmation screens display goes through the floored one — the ERC-20 amount,
the ETH value and max fee (`src/popup/views/approval.js`), and the swap's
`Amount` and `Min. received` lines (`src/shared/uniswap.js`). The history and
balance lists (`src/shared/transactions.js`) use the unfloored one: the
transaction detail view is the authoritative record and already shows exact
precision. The 4-decimal rule is unchanged everywhere else, including for
the ETH value and max fee (`src/popup/views/approval.js`), the swap's `Amount`
and `Min. received` lines (`src/shared/uniswap.js`), the Send screen's
`Current balance` (`src/popup/views/send.js`), and the balance and network fee
on the confirmation screen for the wallet's own send
(`src/popup/views/confirmTx.js`), so a fee reads the same there as on the
approval screen. For this the ETH balance is stored exactly. A token balance is
stored to six decimal places, and a holding below 0.000001 is not listed at all.
The history and balance lists (`src/shared/transactions.js`) use the unfloored
one: the transaction detail view is the authoritative record and already shows
exact precision. The 4-decimal rule is unchanged everywhere else, including for
amounts at or above the floor on the approval screens.
The floor applies only where the token's scale is known. Where it is not, the
+12
View File
@@ -45,6 +45,18 @@ but the review is broader than any of them.
# Completed Steps
- 2026-10-04: The Send and confirmation screens no longer show an ETH balance or
a network fee below 0.000001 as `0.0`
([#343](https://git.eeqj.de/sneak/AutistMask/issues/343)). The stored ETH
balance (`src/shared/balances.js`) and the confirmation screen's fee were each
cut to six decimal places by a rule of their own. The ETH balance is now
stored exactly, and the Send screen's `Current balance`, and the confirmation
screen's balance, fee, reserve and insufficient-balance messages, go through
`truncateAmountNeverZero()` in `src/shared/amountDisplay.js`, the helper the
approval screen already used, so the same fee reads the same on both screens.
Token balances are still stored to six decimal places: that cut is also what
leaves a holding below 0.000001 off the balance list, and keeps the stored
string within the 18 decimals the balance check reads.
- 2026-10-03: The typed-data signing screen warns for a token permission, and
names the primary type ethers signs
([#400](https://git.eeqj.de/sneak/AutistMask/issues/400)). A Permit or Permit2
+20 -15
View File
@@ -31,6 +31,9 @@ const {
transferAmountUnits,
} = require("../../shared/transferAmount");
const { assertWithinCeilings } = require("../../shared/approvalVerify");
// Every balance and fee this screen shows goes through it, as the approval
// screen's do, so the same value reads the same on both.
const { truncateAmountNeverZero } = require("../../shared/amountDisplay");
const {
CODES,
FEE_PENDING,
@@ -150,11 +153,17 @@ function show(txInfo) {
$("confirm-balance").textContent =
bal == null
? "unknown (" + symbol + ")"
: valueWithUsd(bal + " " + symbol, balUsd);
: valueWithUsd(
truncateAmountNeverZero(bal) + " " + symbol,
balUsd,
);
} else {
const bal = txInfo.balance || "0";
const balUsd = ethPrice ? parseFloat(bal) * ethPrice : null;
$("confirm-balance").textContent = valueWithUsd(bal + " ETH", balUsd);
$("confirm-balance").textContent = valueWithUsd(
truncateAmountNeverZero(bal) + " ETH",
balUsd,
);
}
// Check for warnings (synchronous local checks)
@@ -249,7 +258,7 @@ function renderValidation(txInfo) {
: "Insufficient " +
symbol +
" balance. You have " +
txInfo.tokenBalance +
truncateAmountNeverZero(txInfo.tokenBalance) +
" " +
symbol +
" but are trying to send " +
@@ -262,7 +271,7 @@ function renderValidation(txInfo) {
if (codes.includes(CODES.INSUFFICIENT_ETH)) {
messages.push(
"Insufficient balance. You have " +
txInfo.balance +
truncateAmountNeverZero(txInfo.balance || "0") +
" ETH but are trying to send " +
txInfo.amount +
" ETH.",
@@ -305,14 +314,6 @@ function setVisible(id, visible) {
$(id).style.visibility = visible ? "visible" : "hidden";
}
// A fee in wei as an ETH string, truncated to 6 decimal places.
function formatFeeEth(wei) {
const parts = formatEther(wei).split(".");
const dec =
parts.length > 1 ? parts[1].slice(0, 6).replace(/0+$/, "") || "0" : "0";
return parts[0] + "." + dec + " ETH";
}
async function estimateGas(txInfo) {
try {
const provider = getProvider(state.rpcUrl, state.networkId);
@@ -365,18 +366,22 @@ async function estimateGas(txInfo) {
if (estimateWei !== null && estimateWei < gasCostWei) {
$("confirm-fee-amount").textContent = valueWithUsd(
"~" + formatFeeEth(estimateWei),
"~" +
truncateAmountNeverZero(formatEther(estimateWei)) +
" ETH",
usd(estimateWei),
);
$("confirm-fee-reserve").textContent =
"up to " + formatFeeEth(gasCostWei) + " reserved";
"up to " +
truncateAmountNeverZero(formatEther(gasCostWei)) +
" ETH reserved";
setVisible("confirm-fee-reserve", true);
} else {
// No spread to report: either there is no estimate, or the node
// quotes a gas price at or above maxFeePerGas, so the expected
// cost is not below the reserve. Show the reserve alone.
$("confirm-fee-amount").textContent = valueWithUsd(
formatFeeEth(gasCostWei),
truncateAmountNeverZero(formatEther(gasCostWei)) + " ETH",
usd(gasCostWei),
);
setVisible("confirm-fee-reserve", false);
+8 -2
View File
@@ -16,6 +16,7 @@ const { resolveTokenDecimals } = require("../../shared/approvalAmount");
const { resolveSymbol } = require("../../shared/tokenList");
const { isLowHolderCount } = require("../../shared/holders");
const { isSpoofedSymbol } = require("../../shared/symbolSpoof");
const { truncateAmountNeverZero } = require("../../shared/amountDisplay");
const { getAddress } = require("ethers");
const ZERO_ADDRESS = "0x0000000000000000000000000000000000000000";
@@ -150,7 +151,9 @@ function updateSendBalance() {
const token = state.selectedToken || $("send-token").value;
if (token === "ETH") {
$("send-balance").textContent =
"Current balance: " + (addr.balance || "0") + " ETH";
"Current balance: " +
truncateAmountNeverZero(addr.balance || "0") +
" ETH";
} else {
const tb = (addr.tokenBalances || []).find(
(t) => t.address.toLowerCase() === token.toLowerCase(),
@@ -167,7 +170,10 @@ function updateSendBalance() {
$("send-balance").textContent =
bal == null
? "Current balance: unknown (" + symbol + ")"
: "Current balance: " + bal + " " + symbol;
: "Current balance: " +
truncateAmountNeverZero(bal) +
" " +
symbol;
}
}
+10 -9
View File
@@ -52,14 +52,12 @@ function requireNetworkId(networkId) {
return net;
}
function formatBalance(wei) {
const eth = formatEther(wei);
const parts = eth.split(".");
if (parts.length === 1) return eth + ".0";
const dec = parts[1].slice(0, 6).replace(/0+$/, "") || "0";
return parts[0] + "." + dec;
}
// A token balance cut to six decimal places. Two things rely on the cut: a
// holding below 0.000001 comes out as "0.0" and is left off the balance list as
// dust, and the stored string never carries more decimals than the balance
// check on the confirmation screen can read (18, in txValidation.js), whatever
// scale the token declares. Screens truncate it again for display, through
// src/shared/amountDisplay.js.
function formatTokenBalance(raw, decimals) {
const val = formatUnits(raw, decimals);
const parts = val.split(".");
@@ -221,7 +219,10 @@ async function refreshBalances(
provider
.getBalance(addr.address)
.then((bal) => {
addr.balance = formatBalance(bal);
// Exact, never cut: the screens truncate for display
// themselves, and a cut here stores a small nonzero
// balance as zero.
addr.balance = formatEther(bal);
log.debugf("ETH balance", addr.address, addr.balance);
})
.catch((e) => {
+12 -12
View File
@@ -1414,9 +1414,10 @@ test("a rejected dust threshold shifts no layout (#233)", async (env) => {
// on opposite sides of the reserve while sitting on the same side of the
// estimate.
// The balance the funded fixture serves, and the amounts sent against it.
// The balance the funded fixture serves, as the Send and confirmation screens
// show it, and the amounts sent against it.
const FUNDED_ETH_WEI = 10n ** 18n;
const FUNDED_ETH_TEXT = "1.0";
const FUNDED_ETH_TEXT = "1.0000";
const COMFORTABLE_AMOUNT = "0.1";
const OVER_BALANCE_AMOUNT = "2.0";
@@ -1430,7 +1431,7 @@ const GAP_AMOUNT = formatEther(FUNDED_ETH_WEI - FEE_ESTIMATE_WEI);
// fee test: it covers the expected cost to the wei and falls short of the
// reserve, so the same swap flips this assertion too — through a different
// balance and a different message than the ETH path uses.
const TOKEN_BALANCE_TEXT = "1.5";
const TOKEN_BALANCE_TEXT = "1.5000";
const TOKEN_AMOUNT = "0.25";
const OVER_TOKEN_AMOUNT = "9.0";
const FEE_ONLY_ETH_WEI = FEE_ESTIMATE_WEI;
@@ -1439,16 +1440,15 @@ function toHexWei(wei) {
return "0x" + wei.toString(16);
}
// A fee in wei as the confirmation screen writes it. Deliberately a second
// implementation of formatFeeEth() from src/popup/views/confirmTx.js rather
// than an import of it: that module pulls in the whole popup and cannot be
// required outside a browser, and asserting against an independent rendering
// is stronger than asserting a function equals itself.
// A fee in wei as the confirmation screen writes it: truncated to four decimal
// places (README.md, Display Consistency). Deliberately a second
// implementation rather than an import of src/shared/amountDisplay.js:
// asserting against an independent rendering is stronger than asserting a
// function equals itself. The fixture's fees are above 0.0001 ETH, so the
// nonzero floor never applies here.
function feeEth(wei) {
const parts = formatEther(wei).split(".");
const dec =
parts.length > 1 ? parts[1].slice(0, 6).replace(/0+$/, "") || "0" : "0";
return parts[0] + "." + dec + " ETH";
const [whole, frac = ""] = formatEther(wei).split(".");
return whole + "." + (frac + "0000").slice(0, 4) + " ETH";
}
// What the confirmation screen is showing right now, read out of the DOM in
+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]/);
});
});
+3 -3
View File
@@ -389,7 +389,7 @@ describe("a scale the explorer's own rows disagree about", () => {
expect(txInfo.tokenBalance).toBe("5.0");
confirmTx.show(txInfo);
await settle();
expect(text("confirm-balance")).toBe("5.0 NOVEL");
expect(text("confirm-balance")).toBe("5.0000 NOVEL");
expect(errors()).toBe("");
expect(sendDisabled()).toBe(false);
});
@@ -431,7 +431,7 @@ describe("the confirmation screen tells an unknown balance from a zero one", ()
const zero = await render("0.0");
expect(unknown.balance).not.toBe(zero.balance);
expect(unknown.balance).toBe("unknown (NOVEL)");
expect(zero.balance).toBe("0.0 NOVEL");
expect(zero.balance).toBe("0.0000 NOVEL");
});
// Both hit INSUFFICIENT_TOKEN — an unknown balance is treated as nothing to
@@ -443,7 +443,7 @@ describe("the confirmation screen tells an unknown balance from a zero one", ()
expect(unknown.errors).not.toBe(zero.errors);
expect(unknown.errors).toContain("This token&#39;s balance is unknown");
expect(unknown.errors).not.toContain("You have");
expect(zero.errors).toContain("You have 0.0 NOVEL");
expect(zero.errors).toContain("You have 0.0000 NOVEL");
expect(zero.errors).not.toContain("balance is unknown");
});
});