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) Failing after 2s
e2e / e2e-chrome (push) Failing after 2s
e2e / e2e-firefox (push) Failing after 2s
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 `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 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 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 user, or has 1,000 or more holders; a token claiming a symbol from the bundled
does not track is dust and is not shown; a tracked token's holding is shown list from any other contract address is always dropped, and so is any token
whatever its size, also while "Show tracked tokens with zero balance" is off, claiming a symbol that belongs to the native asset and therefore has no
and one too small for the list's four decimal places reads `0.0000`. A token legitimate contract at all (`"ETH"`). That filter is unconditional — the "Hide
claiming a symbol from the bundled list from any other contract address is tokens with fewer than 1,000 holders" setting governs the transaction history
always dropped, and so is any token claiming a symbol that belongs to the native and the send-screen token selector, not this list. `fetchTokenBalances()` stores
asset and therefore has no legitimate contract at all (`"ETH"`). That filter is every nonzero holding of a token it admits exactly, however small, but a holding
unconditional — the "Hide tokens with fewer than 1,000 holders" setting governs below 0.000001 is left out of the balance lists, the send-screen token selector,
the transaction history and the send-screen token selector, not this list. the address total and the remove-address warning (`isBelowOneMillionth()` in
Tracked tokens with a zero balance are listed as well while "Show tracked tokens `src/shared/amountDisplay.js`). The Send and confirmation screens show it when
with zero balance" is on. 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 #### 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 token balance or a network fee below 0.000001 as zero
([#343](https://git.eeqj.de/sneak/AutistMask/issues/343)). The stored balances ([#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 (`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, six decimal places by a rule of their own, and a token holding cut to zero was
whatever decimals a token declares; the balance check reads a token balance to dropped. Balances are now stored exactly, whatever decimals a token declares,
its first 18 places, the most an amount can have. A token holding below and every nonzero token holding is kept; the balance check reads a token
0.000001 is still not listed, but only for a token the user does not track, so balance to its first 18 places, the most an amount can have. The balance
a tracked token's holding reaches the Send screen and the send is checked lists, the send-screen token selector, the address total and the
against it. The Send screen's `Current balance`, and the confirmation screen's 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 balance, fee, reserve and insufficient-balance messages, go through
`truncateAmountNeverZero()` in `src/shared/amountDisplay.js`, the helper the `truncateAmountNeverZero()` in `src/shared/amountDisplay.js`, the helper the
approval screen already used. The confirmation and approval screens both 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 // 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 // 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 // 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) { function balanceWarningHtml(addr) {
if (!addressHoldsFunds(addr)) return " "; if (!addressHoldsFunds(addr)) return " ";
const line = formatAddressTotal(getAddressValue(addr)); 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 // reasoning behind it are; it is re-exported below so views keep importing
// it from here. // it from here.
const { formatEther } = require("ethers"); const { formatEther } = require("ethers");
const { truncateAmountNeverZero } = require("../../shared/amountDisplay"); const {
truncateAmountNeverZero,
isBelowOneMillionth,
} = require("../../shared/amountDisplay");
const { DEBUG } = require("../../shared/constants"); const { DEBUG } = require("../../shared/constants");
const { escapeHtml } = require("../../shared/html"); const { escapeHtml } = require("../../shared/html");
const { isDebug } = require("../../shared/log"); const { isDebug } = require("../../shared/log");
@@ -303,6 +306,9 @@ function balanceLinesForAddress(addr, trackedTokens, showZero) {
); );
const seen = new Set(); const seen = new Set();
for (const t of addr.tokenBalances || []) { 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 // 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 // of zero, so the show-zero setting has no say over it: hiding it
// would be asserting the zero nobody established. Anything that does // 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 // Whether an address holds anything at all: ETH or any ERC-20 the wallet
// knows about. Deliberately unrounded — the rendered lines round to four // knows about, except a token holding below 0.000001, which the balance list
// decimals, so a dust balance displays as 0.0000 while still being real // under the remove-address warning leaves out too. Deliberately unrounded —
// money at a real address. Callers that warn about holdings must ask this, // the rendered lines round to four decimals, so a dust balance displays as
// not the rendered figure. // 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) { function addressHoldsFunds(addr) {
if (!addr) return false; if (!addr) return false;
if (parseFloat(addr.balance || "0") > 0) return true; if (parseFloat(addr.balance || "0") > 0) return true;
for (const t of addr.tokenBalances || []) { for (const t of addr.tokenBalances || []) {
if (isBelowOneMillionth(t.balance)) continue;
// A null balance is a holding whose amount could not be stated — // 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 // balances.js drops a row of zero base units before the scale is
// consulted, so a row that survived with no quantity is holding // 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 { resolveSymbol } = require("../../shared/tokenList");
const { isLowHolderCount } = require("../../shared/holders"); const { isLowHolderCount } = require("../../shared/holders");
const { isSpoofedSymbol } = require("../../shared/symbolSpoof"); const { isSpoofedSymbol } = require("../../shared/symbolSpoof");
const { truncateAmountNeverZero } = require("../../shared/amountDisplay"); const {
truncateAmountNeverZero,
isBelowOneMillionth,
} = require("../../shared/amountDisplay");
const { getAddress } = require("ethers"); const { getAddress } = require("ethers");
const ZERO_ADDRESS = "0x0000000000000000000000000000000000000000"; const ZERO_ADDRESS = "0x0000000000000000000000000000000000000000";
@@ -126,6 +129,10 @@ function renderSendTokenSelect(addr) {
(state.fraudContracts || []).map((a) => a.toLowerCase()), (state.fraudContracts || []).map((a) => a.toLowerCase()),
); );
for (const t of addr.tokenBalances || []) { 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 (isSpoofedSymbol(t.symbol, t.address)) continue;
if (fraudSet.has(t.address.toLowerCase())) continue; if (fraudSet.has(t.address.toLowerCase())) continue;
// An unknown holder count does not withhold a token the user holds: // 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 // (`src/shared/uniswap.js`) — and a fix applied to one of them left the other
// two showing a different number for the same value. // two showing a different number for the same value.
// //
// The two functions below are the two policies, not two implementations of // The two truncation functions below are the two policies, not two
// one: summary lists truncate, and the screens that state what is being // implementations of one: summary lists truncate, and the screens that state
// authorized truncate with a floor. Keeping them adjacent is the point, so a // what is being authorized truncate with a floor. Keeping them adjacent is the
// change to the rule cannot reach one screen and miss another. // point, so a change to the rule cannot reach one screen and miss another.
// Truncate to exactly four decimal places. Truncation, never rounding: an // Truncate to exactly four decimal places. Truncation, never rounding: an
// amount must never be displayed as larger than it is, so 0.99999 stays // 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); 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 // 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) { function formatTokenBalance(raw, decimals) {
const val = formatUnits(raw, decimals); const val = formatUnits(raw, decimals);
const parts = val.split("."); const parts = val.split(".");
@@ -162,19 +165,6 @@ async function fetchTokenBalances(address, blockscoutUrl, trackedTokens) {
// Skip spam tokens the user never asked to see // Skip spam tokens the user never asked to see
if (!isKnown && !isTracked && !hasEnoughHolders) continue; 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. // Skip tokens spoofing a known symbol from a different address.
// Every row here is an ERC-20 the explorer reported, so it has a // 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 // 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. // Price fetching with 5-minute cache, USD formatting, value aggregation.
const { getTopTokenPrices } = require("./tokenList"); const { getTopTokenPrices } = require("./tokenList");
const { isBelowOneMillionth } = require("./amountDisplay");
const PRICE_CACHE_TTL = 300000; // 5 minutes const PRICE_CACHE_TTL = 300000; // 5 minutes
@@ -78,6 +79,9 @@ function getAddressValue(addr) {
let usd = parseFloat(addr.balance || "0") * prices.ETH; let usd = parseFloat(addr.balance || "0") * prices.ETH;
let partial = false; let partial = false;
for (const token of addr.tokenBalances || []) { 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 // 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 // quantity to price — but it is still a holding, and a total that
// silently omits it would read as complete. That is exactly what // 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 // 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 // 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 // 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 // own, and a token holding cut to zero was dropped, while the approval screen
// src/shared/amountDisplay.js with the nonzero floor. The balances are now // showed the same fee through src/shared/amountDisplay.js with the nonzero
// stored exactly, and the screens show them and the fee through that helper. // 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 // Driven through the real refreshBalances(), Send screen, confirmation screen
// and approval screen, with only the node, the explorer and the DOM stubbed: a // and approval screen, with only the node, the explorer and the DOM stubbed: a
@@ -117,10 +118,12 @@ function makeEl(id) {
contains: () => false, contains: () => false,
}, },
handlers, handlers,
children: [],
addEventListener(name, fn) { addEventListener(name, fn) {
handlers.set(name, fn); handlers.set(name, fn);
}, },
appendChild(child) { appendChild(child) {
this.children.push(child);
return child; return child;
}, },
querySelectorAll: () => [], querySelectorAll: () => [],
@@ -143,7 +146,12 @@ global.navigator = { clipboard: { writeText() {} } };
const { refreshBalances } = require("../src/shared/balances"); const { refreshBalances } = require("../src/shared/balances");
const { state } = require("../src/shared/state"); 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 send = require("../src/popup/views/send");
const confirmTx = require("../src/popup/views/confirmTx"); const confirmTx = require("../src/popup/views/confirmTx");
const approval = require("../src/popup/views/approval"); 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 () => { test("with more than 18 decimals and a holding below 10^-18", async () => {
state.trackedTokens[0].decimals = 24; state.trackedTokens[0].decimals = 24;
// One base unit, 0.000000000000000000000001 TOK, and no ETH, so the // One base unit, 0.000000000000000000000001 TOK.
// token is all the address holds. await refreshWith(10n ** 18n, [tokenRow(1n, { decimals: "24" })]);
await refreshWith(0n, [tokenRow(1n, { decimals: "24" })]);
state.selectedToken = TOKEN; state.selectedToken = TOKEN;
send.updateSendBalance(); send.updateSendBalance();
expect(text("send-balance")).toBe( 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" + "You have 0.000000000000000000000001 TOK but are trying to send" +
" 0.000000000000000001 TOK.", " 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]; const addr = state.wallets[0].addresses[0];
expect( expect(
balanceLinesForAddress(addr, state.trackedTokens, false), balanceLinesForAddress(addr, state.trackedTokens, false),
).not.toContain(TOKEN);
expect(
balanceLinesForAddress(addr, state.trackedTokens, true),
).toContain(`data-token="${TOKEN}"`); ).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([]);
}); });
}); });