Compare commits

..
1 Commits
Author SHA1 Message Date
clawbot 090a5e785c fix: show balances and fees below 0.000001 as nonzero on the send screens (closes #343)
check / check (push) Successful in 2m28s
e2e / e2e-chrome (push) Successful in 3m15s
e2e / e2e-firefox (push) Successful in 2m38s
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; a token declaring more than 18 decimals is cut to 18, the
most the balance check reads. A token holding below 0.000001 is still left off
the lists as dust, except for a token the user tracks, whose holding now
reaches the Send screen and is what the send is checked against. 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 04:45:02 +00:00
7 changed files with 36 additions and 72 deletions
+16 -17
View File
@@ -899,14 +899,15 @@ 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`), whatever value included. Balances are stored exactly (`src/shared/balances.js`), so a
decimals a token declares, so a balance below the floor reaches these screens as balance below the floor reaches these screens as it is; the one cut is that a
it is. The history list (`src/shared/transactions.js`) uses the unfloored one: token declaring more than 18 decimals is stored to 18, the most the confirmation
the transaction detail view is the authoritative record and already shows exact screen's balance check reads. The history list (`src/shared/transactions.js`)
precision. The balance lists use neither: they round to four places with uses the unfloored one: the transaction detail view is the authoritative record
`toFixed(4)` (`balanceLine()` in `src/popup/views/helpers.js`). The 4-decimal and already shows exact precision. The balance lists use neither: they round to
rule is unchanged everywhere else, including for amounts at or above the floor four places with `toFixed(4)` (`balanceLine()` in `src/popup/views/helpers.js`).
on the approval screens. 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 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
@@ -1061,15 +1062,13 @@ 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, also while "Show tracked tokens with zero balance" is off, whatever its size. A token claiming a symbol from the bundled list from any
and one too small for the list's four decimal places reads `0.0000`. A token other contract address is always dropped, and so is any token claiming a symbol
claiming a symbol from the bundled list from any other contract address is that belongs to the native asset and therefore has no legitimate contract at all
always dropped, and so is any token claiming a symbol that belongs to the native (`"ETH"`). That filter is unconditional — the "Hide tokens with fewer than 1,000
asset and therefore has no legitimate contract at all (`"ETH"`). That filter is holders" setting governs the transaction history and the send-screen token
unconditional — the "Hide tokens with fewer than 1,000 holders" setting governs selector, not this list. Tracked tokens with a zero balance are listed as well
the transaction history and the send-screen token selector, not this list. while "Show tracked tokens with zero balance" is on.
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
+10 -11
View File
@@ -50,17 +50,16 @@ 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,
whatever decimals a token declares; the balance check reads a token balance to except that a token declaring more than 18 decimals is stored to 18, the most
its first 18 places, the most an amount can have. A token holding below the balance check reads. A token holding below 0.000001 is still not listed,
0.000001 is still not listed, but only for a token the user does not track, so but only for a token the user does not track, so a tracked token's holding
a tracked token's holding reaches the Send screen and the send is checked reaches the Send screen and the send is checked against it. The Send screen's
against it. The Send screen's `Current balance`, and the confirmation screen's `Current balance`, and the confirmation screen's balance, fee, reserve and
balance, fee, reserve and insufficient-balance messages, go through insufficient-balance messages, go through `truncateAmountNeverZero()` in
`truncateAmountNeverZero()` in `src/shared/amountDisplay.js`, the helper the `src/shared/amountDisplay.js`, the helper the approval screen already used.
approval screen already used. The confirmation and approval screens both The confirmation and approval screens both render the fee through
render the fee through `formatFee()` in `src/popup/views/helpers.js`, which `formatFee()` in `src/popup/views/helpers.js`, which prices the exact fee in
prices the exact fee in USD, so the same fee reads the same on both, USD value USD, so the same fee reads the same on both, USD value included.
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 line goes through formatFee(), as the approval screen's // The fee lines go 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. // figure, so it never understates what the fee costs.
function formatFee(wei) { function formatFee(wei) {
const eth = formatEther(wei); const eth = formatEther(wei);
const ethPrice = getPrice("ETH"); const ethPrice = getPrice("ETH");
+5 -3
View File
@@ -17,6 +17,7 @@ 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).
@@ -52,13 +53,14 @@ function requireNetworkId(networkId) {
return net; return net;
} }
// A token balance as an exact decimal string, never cut: a cut stores a small // A token balance as a decimal string, exact except for a token that declares
// nonzero holding as zero. // 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).
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].replace(/0+$/, "") || "0"; const dec = parts[1].slice(0, SCALE_DECIMALS).replace(/0+$/, "") || "0";
return parts[0] + "." + dec; return parts[0] + "." + dec;
} }
+1 -9
View File
@@ -139,15 +139,7 @@ function validateTransfer({
const feeFp = known ? feeWei : null; const feeFp = known ? feeWei : null;
if (isErc20) { if (isErc20) {
// A token can declare more than 18 decimals, and its balance is const tokenFp = toFixedPoint(tokenBalance) ?? 0n;
// 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);
+2 -30
View File
@@ -147,10 +147,6 @@ 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";
@@ -297,8 +293,8 @@ describe("a tracked token holding below 0.000001 never renders as zero", () => {
); );
}); });
// The stored balance keeps all 24 places, and the balance check reads the // Past 18 places the stored balance is cut: the balance check reads 18,
// first 18 of them rather than refusing it as no balance at all. // and refuses a balance string longer than that 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.
@@ -309,30 +305,6 @@ 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, [