Compare commits

..
1 Commits
Author SHA1 Message Date
clawbot 504a25dead fix: show balances and fees below 0.000001 as nonzero on the send screens (closes #343)
check / check (push) Failing after 2m21s
e2e / e2e-chrome (push) Successful in 3m10s
e2e / e2e-firefox (push) Successful in 2m35s
The stored ETH and token balances and the send-confirm screen's fee were each
cut to six decimal places, so a value below 0.000001 read as zero. 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 left off the lists as dust, except for a token
the user tracks. 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. The balance lists still round with
toFixed(4).

Model: opus-5-5
2026-10-04 05:50:41 +00:00
7 changed files with 72 additions and 36 deletions
+17 -16
View File
@@ -899,15 +899,14 @@ on the confirmation screen for the wallet's own send
(`src/popup/views/confirmTx.js`). Both screens render a network fee through
`formatFee()` in `src/popup/views/helpers.js`, which prices the exact fee in USD
rather than its truncated figure, so the same fee reads the same on both, USD
value included. Balances are stored exactly (`src/shared/balances.js`), so a
balance below the floor reaches these screens as it is; the one cut is that a
token declaring more than 18 decimals is stored to 18, the most the confirmation
screen's balance check reads. The history list (`src/shared/transactions.js`)
uses the unfloored one: the transaction detail view is the authoritative record
and already shows exact precision. The balance lists use neither: they round to
four places with `toFixed(4)` (`balanceLine()` in `src/popup/views/helpers.js`).
The 4-decimal rule is unchanged everywhere else, including for amounts at or
above the floor on the approval screens.
value included. Balances are stored exactly (`src/shared/balances.js`), whatever
decimals a token declares, so a balance below the floor reaches these screens as
it is. The history list (`src/shared/transactions.js`) uses the unfloored one:
the transaction detail view is the authoritative record and already shows exact
precision. The balance lists use neither: they round to four places with
`toFixed(4)` (`balanceLine()` in `src/popup/views/helpers.js`). 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
approval screen states base units instead of a quantity — see Unknown token
@@ -1062,13 +1061,15 @@ 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. 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.
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.
#### Stored state and its version
+11 -10
View File
@@ -50,16 +50,17 @@ but the review is broader than any of them.
([#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,
except that a token declaring more than 18 decimals is stored to 18, the most
the balance check reads. 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 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 render the fee through
`formatFee()` in `src/popup/views/helpers.js`, which prices the exact fee in
USD, so the same fee reads the same on both, USD value included.
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
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
render the fee through `formatFee()` in `src/popup/views/helpers.js`, which
prices the exact fee in USD, so the same fee reads the same on both, USD value
included.
- 2026-10-04: Settings lists the sites connected without "Remember", and
removing a site there disconnects it
([#406](https://git.eeqj.de/sneak/AutistMask/issues/406)). Such a connection
+1 -1
View File
@@ -361,7 +361,7 @@ async function estimateGas(txInfo) {
// flight; a stale fee must not reach the screen or the balance check.
if (pendingTx !== txInfo) return;
// The fee lines go through formatFee(), as the approval screen's
// The fee line goes through formatFee(), as the approval screen's
// does, so the same fee reads the same on both.
if (estimateWei !== null && estimateWei < gasCostWei) {
$("confirm-fee-amount").textContent = "~" + formatFee(estimateWei);
+1 -1
View File
@@ -248,7 +248,7 @@ function unknownableAmount(balance) {
// A network fee in wei as the confirmation and approval screens both show it:
// the ETH figure through truncateAmountNeverZero(), then its USD value when the
// ETH price is known. The USD value is of the exact fee, not of the truncated
// figure, so it never understates what the fee costs.
// figure.
function formatFee(wei) {
const eth = formatEther(wei);
const ethPrice = getPrice("ETH");
+3 -5
View File
@@ -17,7 +17,6 @@ const { LOW_HOLDER_THRESHOLD, parseHoldersCount } = require("./holders");
const { isSpoofedSymbol } = require("./symbolSpoof");
const { toDecimals } = require("./transferAmount");
const { resolveTokenDecimals } = require("./approvalAmount");
const { SCALE_DECIMALS } = require("./txValidation");
// Use a static network to skip auto-detection (which can fail and cause
// "could not coalesce error" on some RPC endpoints like Cloudflare).
@@ -53,14 +52,13 @@ function requireNetworkId(networkId) {
return net;
}
// A token balance as a decimal string, exact except for a token that declares
// more than 18 decimals: that one is cut to 18 places, the most the balance
// check on the confirmation screen reads (SCALE_DECIMALS in txValidation.js).
// A token balance as an exact decimal string, never cut: a cut stores a small
// nonzero holding as zero.
function formatTokenBalance(raw, decimals) {
const val = formatUnits(raw, decimals);
const parts = val.split(".");
if (parts.length === 1) return val + ".0";
const dec = parts[1].slice(0, SCALE_DECIMALS).replace(/0+$/, "") || "0";
const dec = parts[1].replace(/0+$/, "") || "0";
return parts[0] + "." + dec;
}
+9 -1
View File
@@ -139,7 +139,15 @@ function validateTransfer({
const feeFp = known ? feeWei : null;
if (isErc20) {
const tokenFp = toFixedPoint(tokenBalance) ?? 0n;
// A token can declare more than 18 decimals, and its balance is
// stored with all of them. Only the first 18 places (SCALE_DECIMALS)
// are read: an amount with more was refused above, so the places
// after them cannot decide whether the amount fits.
const tokenText =
typeof tokenBalance === "string"
? tokenBalance.replace(/(\.\d{18})\d+$/, "$1")
: tokenBalance;
const tokenFp = toFixedPoint(tokenText) ?? 0n;
if (amountFp > tokenFp) codes.push(CODES.INSUFFICIENT_TOKEN);
if (feeFp !== null && feeFp > ethFp) {
codes.push(CODES.INSUFFICIENT_ETH_FOR_FEE);
+30 -2
View File
@@ -147,6 +147,10 @@ const { prices, clearPrices } = 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";
@@ -293,8 +297,8 @@ describe("a tracked token holding below 0.000001 never renders as zero", () => {
);
});
// Past 18 places the stored balance is cut: the balance check reads 18,
// and refuses a balance string longer than that as no balance at all.
// 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.
@@ -305,6 +309,30 @@ describe("a tracked token holding below 0.000001 never renders as zero", () => {
expect(errors()).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, and no ETH, so the
// token is all the address holds.
await refreshWith(0n, [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(errors()).toContain(
"You have 0.000000000000000000000001 TOK but are trying to send" +
" 0.000000000000000001 TOK.",
);
// Listed with zero balances hidden, and counted as funds.
const addr = state.wallets[0].addresses[0];
expect(
balanceLinesForAddress(addr, state.trackedTokens, false),
).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, [