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 (`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 `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 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 value included. Balances are stored exactly (`src/shared/balances.js`), whatever
balance below the floor reaches these screens as it is; the one cut is that a decimals a token declares, so a balance below the floor reaches these screens as
token declaring more than 18 decimals is stored to 18, the most the confirmation it is. The history list (`src/shared/transactions.js`) uses the unfloored one:
screen's balance check reads. The history list (`src/shared/transactions.js`) the transaction detail view is the authoritative record and already shows exact
uses the unfloored one: the transaction detail view is the authoritative record precision. The balance lists use neither: they round to four places with
and already shows exact precision. The balance lists use neither: they round to `toFixed(4)` (`balanceLine()` in `src/popup/views/helpers.js`). The 4-decimal
four places with `toFixed(4)` (`balanceLine()` in `src/popup/views/helpers.js`). rule is unchanged everywhere else, including for amounts at or above the floor
The 4-decimal rule is unchanged everywhere else, including for amounts at or on the approval screens.
above the floor on the approval screens.
The floor applies only where the token's scale is known. Where it is not, the 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 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 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 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 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 whatever its size, also while "Show tracked tokens with zero balance" is off,
other contract address is always dropped, and so is any token claiming a symbol and one too small for the list's four decimal places reads `0.0000`. A token
that belongs to the native asset and therefore has no legitimate contract at all claiming a symbol from the bundled list from any other contract address is
(`"ETH"`). That filter is unconditional — the "Hide tokens with fewer than 1,000 always dropped, and so is any token claiming a symbol that belongs to the native
holders" setting governs the transaction history and the send-screen token asset and therefore has no legitimate contract at all (`"ETH"`). That filter is
selector, not this list. Tracked tokens with a zero balance are listed as well unconditional — the "Hide tokens with fewer than 1,000 holders" setting governs
while "Show tracked tokens with zero balance" is on. 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 #### 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 ([#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. Balances are now stored exactly,
except that a token declaring more than 18 decimals is stored to 18, the most whatever decimals a token declares; the balance check reads a token balance to
the balance check reads. A token holding below 0.000001 is still not listed, its first 18 places, the most an amount can have. A token holding below
but only for a token the user does not track, so a tracked token's holding 0.000001 is still not listed, but only for a token the user does not track, so
reaches the Send screen and the send is checked against it. The Send screen's a tracked token's holding reaches the Send screen and the send is checked
`Current balance`, and the confirmation screen's balance, fee, reserve and against it. The Send screen's `Current balance`, and the confirmation screen's
insufficient-balance messages, go through `truncateAmountNeverZero()` in balance, fee, reserve and insufficient-balance messages, go through
`src/shared/amountDisplay.js`, the helper the approval screen already used. `truncateAmountNeverZero()` in `src/shared/amountDisplay.js`, the helper the
The confirmation and approval screens both render the fee through approval screen already used. The confirmation and approval screens both
`formatFee()` in `src/popup/views/helpers.js`, which prices the exact fee in render the fee through `formatFee()` in `src/popup/views/helpers.js`, which
USD, so the same fee reads the same on both, USD value included. 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 - 2026-10-04: Settings lists the sites connected without "Remember", and
removing a site there disconnects it removing a site there disconnects it
([#406](https://git.eeqj.de/sneak/AutistMask/issues/406)). Such a connection ([#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. // flight; a stale fee must not reach the screen or the balance check.
if (pendingTx !== txInfo) return; 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. // does, so the same fee reads the same on both.
if (estimateWei !== null && estimateWei < gasCostWei) { if (estimateWei !== null && estimateWei < gasCostWei) {
$("confirm-fee-amount").textContent = "~" + formatFee(estimateWei); $("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: // 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 // 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 // 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) { function formatFee(wei) {
const eth = formatEther(wei); const eth = formatEther(wei);
const ethPrice = getPrice("ETH"); const ethPrice = getPrice("ETH");
+3 -5
View File
@@ -17,7 +17,6 @@ const { LOW_HOLDER_THRESHOLD, parseHoldersCount } = require("./holders");
const { isSpoofedSymbol } = require("./symbolSpoof"); const { isSpoofedSymbol } = require("./symbolSpoof");
const { toDecimals } = require("./transferAmount"); const { toDecimals } = require("./transferAmount");
const { resolveTokenDecimals } = require("./approvalAmount"); const { resolveTokenDecimals } = require("./approvalAmount");
const { SCALE_DECIMALS } = require("./txValidation");
// Use a static network to skip auto-detection (which can fail and cause // Use a static network to skip auto-detection (which can fail and cause
// "could not coalesce error" on some RPC endpoints like Cloudflare). // "could not coalesce error" on some RPC endpoints like Cloudflare).
@@ -53,14 +52,13 @@ function requireNetworkId(networkId) {
return net; return net;
} }
// A token balance as a decimal string, exact except for a token that declares // A token balance as an exact decimal string, never cut: a cut stores a small
// more than 18 decimals: that one is cut to 18 places, the most the balance // nonzero holding as zero.
// check on the confirmation screen reads (SCALE_DECIMALS in txValidation.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(".");
if (parts.length === 1) return val + ".0"; 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; return parts[0] + "." + dec;
} }
+9 -1
View File
@@ -139,7 +139,15 @@ function validateTransfer({
const feeFp = known ? feeWei : null; const feeFp = known ? feeWei : null;
if (isErc20) { 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 (amountFp > tokenFp) codes.push(CODES.INSUFFICIENT_TOKEN);
if (feeFp !== null && feeFp > ethFp) { if (feeFp !== null && feeFp > ethFp) {
codes.push(CODES.INSUFFICIENT_ETH_FOR_FEE); 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 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");
const {
addressHoldsFunds,
balanceLinesForAddress,
} = require("../src/popup/views/helpers");
const HOLDER = "0x" + "a".repeat(40); const HOLDER = "0x" + "a".repeat(40);
const RECIPIENT = "0xC0FfEE0000000000000000000000000000c0fFEe"; 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, // The stored balance keeps all 24 places, and the balance check reads the
// and refuses a balance string longer than that as no balance at all. // 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 () => { test("with more than 18 decimals, the send is checked against 18 of them", async () => {
state.trackedTokens[0].decimals = 24; state.trackedTokens[0].decimals = 24;
// 1.5 plus one base unit. // 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(""); 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 () => { test("while an untracked holding below 0.000001 is still not listed", async () => {
state.trackedTokens = []; state.trackedTokens = [];
await refreshWith(10n ** 18n, [ await refreshWith(10n ** 18n, [