Compare commits

..
1 Commits
Author SHA1 Message Date
clawbot 7181f33bee fix: show balances and fees below 0.000001 as nonzero on the send screens (closes #343)
check / check (push) Waiting to run
e2e / e2e-chrome (push) Waiting to run
e2e / e2e-firefox (push) Waiting to run
The stored ETH and token balances and the send-confirm screen's fee were each
cut to six decimal places, and a token holding cut to zero was dropped, so a
value below 0.000001 read as zero. Balances are now stored exactly, whatever
decimals a token declares, and every nonzero token holding is kept; the balance
check reads a token balance to its first 18 places. The balance lists, the
send-screen token selector, the address total and the remove-address warning
leave out a holding below 0.000001 themselves, through isBelowOneMillionth().
The send and send-confirm screens' balances, reserve and insufficient-balance
messages go through truncateAmountNeverZero(). The send-confirm and approval
screens both render the fee through formatFee(), which prices the exact fee in
USD.

Model: opus-5-5
2026-10-04 06:56:03 +00:00
9 changed files with 147 additions and 60 deletions
+12 -11
View File
@@ -1059,17 +1059,18 @@ Which tokens an address shows is decided by `fetchTokenBalances()` in
`src/shared/balances.js`, from the Blockscout `token-balances` response, so
tokens do appear without the user adding them. An ERC-20 is shown when its
balance is nonzero and it is in the bundled known-token list, is tracked by the
user, or has 1,000 or more holders. A holding below 0.000001 of a token the user
does not track is dust and is not shown; a tracked token's holding is shown
whatever its size, also while "Show tracked tokens with zero balance" is off,
and one too small for the list's four decimal places reads `0.0000`. A token
claiming a symbol from the bundled list from any other contract address is
always dropped, and so is any token claiming a symbol that belongs to the native
asset and therefore has no legitimate contract at all (`"ETH"`). That filter is
unconditional — the "Hide tokens with fewer than 1,000 holders" setting governs
the transaction history and the send-screen token selector, not this list.
Tracked tokens with a zero balance are listed as well while "Show tracked tokens
with zero balance" is on.
user, or has 1,000 or more holders; a token claiming a symbol from the bundled
list from any other contract address is always dropped, and so is any token
claiming a symbol that belongs to the native asset and therefore has no
legitimate contract at all (`"ETH"`). That filter is unconditional — the "Hide
tokens with fewer than 1,000 holders" setting governs the transaction history
and the send-screen token selector, not this list. `fetchTokenBalances()` stores
every nonzero holding of a token it admits exactly, however small, but a holding
below 0.000001 is left out of the balance lists, the send-screen token selector,
the address total and the remove-address warning (`isBelowOneMillionth()` in
`src/shared/amountDisplay.js`). The Send and confirmation screens show it when
its token is the one being sent. Tracked tokens with a zero balance are listed
as well while "Show tracked tokens with zero balance" is on.
#### Stored state and its version
+7 -6
View File
@@ -49,12 +49,13 @@ but the review is broader than any of them.
token balance or a network fee below 0.000001 as zero
([#343](https://git.eeqj.de/sneak/AutistMask/issues/343)). The stored balances
(`src/shared/balances.js`) and the confirmation screen's fee were each cut to
six decimal places by a rule of their own. Balances are now stored exactly,
whatever decimals a token declares; the balance check reads a token balance to
its first 18 places, the most an amount can have. A token holding below
0.000001 is still not listed, but only for a token the user does not track, so
a tracked token's holding reaches the Send screen and the send is checked
against it. The Send screen's `Current balance`, and the confirmation screen's
six decimal places by a rule of their own, and a token holding cut to zero was
dropped. Balances are now stored exactly, whatever decimals a token declares,
and every nonzero token holding is kept; the balance check reads a token
balance to its first 18 places, the most an amount can have. The balance
lists, the send-screen token selector, the address total and the
remove-address warning leave out a holding below 0.000001 themselves, as
before. 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. The confirmation and approval screens both
+2 -1
View File
@@ -87,7 +87,8 @@ function recoveryPathText(wallet) {
// AddressDetail, followed by the USD total when there is one to give — no
// total line at all on testnet or before the first price fetch, and no figure
// when every holding here is one with no price, since "$0.00" directly under
// "This address holds a balance." is a contradiction.
// "This address holds a balance." is a contradiction. A token holding below
// 0.000001 does not count, as the lines below leave it out.
function balanceWarningHtml(addr) {
if (!addressHoldsFunds(addr)) return " ";
const line = formatAddressTotal(getAddressValue(addr));
+13 -5
View File
@@ -13,7 +13,10 @@
// reasoning behind it are; it is re-exported below so views keep importing
// it from here.
const { formatEther } = require("ethers");
const { truncateAmountNeverZero } = require("../../shared/amountDisplay");
const {
truncateAmountNeverZero,
isBelowOneMillionth,
} = require("../../shared/amountDisplay");
const { DEBUG } = require("../../shared/constants");
const { escapeHtml } = require("../../shared/html");
const { isDebug } = require("../../shared/log");
@@ -303,6 +306,9 @@ function balanceLinesForAddress(addr, trackedTokens, showZero) {
);
const seen = new Set();
for (const t of addr.tokenBalances || []) {
// A holding below 0.000001 is not listed, tracked or not. A tracked
// token then gets the zero row below while showZero is on.
if (isBelowOneMillionth(t.balance)) continue;
// A null balance is a holding of an unstatable amount, not a holding
// of zero, so the show-zero setting has no say over it: hiding it
// would be asserting the zero nobody established. Anything that does
@@ -333,14 +339,16 @@ function balanceLinesForAddress(addr, trackedTokens, showZero) {
}
// Whether an address holds anything at all: ETH or any ERC-20 the wallet
// knows about. Deliberately unrounded — the rendered lines round to four
// decimals, so a dust balance displays as 0.0000 while still being real
// money at a real address. Callers that warn about holdings must ask this,
// not the rendered figure.
// knows about, except a token holding below 0.000001, which the balance list
// under the remove-address warning leaves out too. Deliberately unrounded —
// the rendered lines round to four decimals, so a dust balance displays as
// 0.0000 while still being real money at a real address. Callers that warn
// about holdings must ask this, not the rendered figure.
function addressHoldsFunds(addr) {
if (!addr) return false;
if (parseFloat(addr.balance || "0") > 0) return true;
for (const t of addr.tokenBalances || []) {
if (isBelowOneMillionth(t.balance)) continue;
// A null balance is a holding whose amount could not be stated —
// balances.js drops a row of zero base units before the scale is
// consulted, so a row that survived with no quantity is holding
+8 -1
View File
@@ -16,7 +16,10 @@ 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 {
truncateAmountNeverZero,
isBelowOneMillionth,
} = require("../../shared/amountDisplay");
const { getAddress } = require("ethers");
const ZERO_ADDRESS = "0x0000000000000000000000000000000000000000";
@@ -126,6 +129,10 @@ function renderSendTokenSelect(addr) {
(state.fraudContracts || []).map((a) => a.toLowerCase()),
);
for (const t of addr.tokenBalances || []) {
// A holding below 0.000001 is left out, as the balance lists leave it
// out. Its token's own screen can still send it: there
// state.selectedToken picks the token, not this list.
if (isBelowOneMillionth(t.balance)) continue;
if (isSpoofedSymbol(t.symbol, t.address)) continue;
if (fraudSet.has(t.address.toLowerCase())) continue;
// An unknown holder count does not withhold a token the user holds:
+19 -5
View File
@@ -6,10 +6,10 @@
// (`src/shared/uniswap.js`) — and a fix applied to one of them left the other
// two showing a different number for the same value.
//
// The two functions below are the two policies, not two implementations of
// one: summary lists truncate, and the screens that state what is being
// authorized truncate with a floor. Keeping them adjacent is the point, so a
// change to the rule cannot reach one screen and miss another.
// The two truncation functions below are the two policies, not two
// implementations of one: summary lists truncate, and the screens that state
// what is being authorized truncate with a floor. Keeping them adjacent is the
// point, so a change to the rule cannot reach one screen and miss another.
// Truncate to exactly four decimal places. Truncation, never rounding: an
// amount must never be displayed as larger than it is, so 0.99999 stays
@@ -43,4 +43,18 @@ function truncateAmountNeverZero(val) {
return parts[0] + "." + parts[1].slice(0, sig + 1);
}
module.exports = { truncateAmount, truncateAmountNeverZero };
// Whether a stored token balance is a holding below 0.000001. The balance
// lists, the send-screen token selector, the address total and the
// remove-address warning leave such a holding out; the Send and confirmation
// screens show it when its token is the one being sent. Exact, because
// src/shared/balances.js stores plain decimal digits: below 0.000001 the
// balance reads "0.000000" and then more digits.
function isBelowOneMillionth(balance) {
return typeof balance === "string" && balance.startsWith("0.000000");
}
module.exports = {
truncateAmount,
truncateAmountNeverZero,
isBelowOneMillionth,
};
+4 -14
View File
@@ -53,7 +53,10 @@ function requireNetworkId(networkId) {
}
// A token balance as an exact decimal string, never cut: a cut stores a small
// nonzero holding as zero.
// nonzero holding as zero. fetchTokenBalances() stores every nonzero holding of
// a token it admits, however small; the screens that leave out one below
// 0.000001 decide that themselves, through isBelowOneMillionth() in
// src/shared/amountDisplay.js.
function formatTokenBalance(raw, decimals) {
const val = formatUnits(raw, decimals);
const parts = val.split(".");
@@ -162,19 +165,6 @@ async function fetchTokenBalances(address, blockscoutUrl, trackedTokens) {
// Skip spam tokens the user never asked to see
if (!isKnown && !isTracked && !hasEnoughHolders) continue;
// Skip dust: a holding below 0.000001 of a token the user does not
// track. A tracked token keeps it, so the Send and confirmation
// screens show that holding and check the send against it rather
// than against zero. Without a scale there is no such judgement to
// make, and the row is kept.
if (
!isTracked &&
scale !== null &&
raw * 1000000n < 10n ** BigInt(scale)
) {
continue;
}
// Skip tokens spoofing a known symbol from a different address.
// Every row here is an ERC-20 the explorer reported, so it has a
// contract address; the native ETH balance is fetched over RPC in
+4
View File
@@ -1,6 +1,7 @@
// Price fetching with 5-minute cache, USD formatting, value aggregation.
const { getTopTokenPrices } = require("./tokenList");
const { isBelowOneMillionth } = require("./amountDisplay");
const PRICE_CACHE_TTL = 300000; // 5 minutes
@@ -78,6 +79,9 @@ function getAddressValue(addr) {
let usd = parseFloat(addr.balance || "0") * prices.ETH;
let partial = false;
for (const token of addr.tokenBalances || []) {
// A holding below 0.000001 is left out, as the balance lists leave it
// out, so the total never counts a holding the list does not show.
if (isBelowOneMillionth(token.balance)) continue;
// A null balance is a holding whose scale nothing knows, so it has no
// quantity to price — but it is still a holding, and a total that
// silently omits it would read as complete. That is exactly what
+78 -17
View File
@@ -4,9 +4,10 @@
// 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, while the approval screen showed the same fee through
// src/shared/amountDisplay.js with the nonzero floor. The balances are now
// stored exactly, and the screens show them and the fee through that helper.
// 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
@@ -117,10 +118,12 @@ function makeEl(id) {
contains: () => false,
},
handlers,
children: [],
addEventListener(name, fn) {
handlers.set(name, fn);
},
appendChild(child) {
this.children.push(child);
return child;
},
querySelectorAll: () => [],
@@ -143,7 +146,12 @@ global.navigator = { clipboard: { writeText() {} } };
const { refreshBalances } = require("../src/shared/balances");
const { state } = require("../src/shared/state");
const { prices, clearPrices } = require("../src/shared/prices");
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");
@@ -311,9 +319,8 @@ describe("a tracked token holding below 0.000001 never renders as zero", () => {
test("with more than 18 decimals and a holding below 10^-18", async () => {
state.trackedTokens[0].decimals = 24;
// One base unit, 0.000000000000000000000001 TOK, and no ETH, so the
// token is all the address holds.
await refreshWith(0n, [tokenRow(1n, { 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(
@@ -325,20 +332,74 @@ describe("a tracked token holding below 0.000001 never renders as zero", () => {
"You have 0.000000000000000000000001 TOK but are trying to send" +
" 0.000000000000000001 TOK.",
);
// Listed with zero balances hidden, and counted as funds.
});
});
// 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("");
await confirmSend("0.0000006", TOKEN);
expect(errors()).toContain(
"You have 0.0000005 TOK but are trying to send 0.0000006 TOK.",
);
});
});
// 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}"`);
expect(addressHoldsFunds(addr)).toBe(true);
});
test("while an untracked holding below 0.000001 is still not listed", async () => {
state.trackedTokens = [];
await refreshWith(10n ** 18n, [
tokenRow(HALF_MICRO_ETH, { holders_count: "50000" }),
]);
expect(state.wallets[0].addresses[0].tokenBalances).toEqual([]);
});
});