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
This commit is contained in:
2026-10-04 05:50:41 +00:00
committed by sneak
parent 49a7da87e8
commit 504a25dead
11 changed files with 531 additions and 83 deletions
+19 -14
View File
@@ -52,19 +52,13 @@ function requireNetworkId(networkId) {
return net;
}
function formatBalance(wei) {
const eth = formatEther(wei);
const parts = eth.split(".");
if (parts.length === 1) return eth + ".0";
const dec = parts[1].slice(0, 6).replace(/0+$/, "") || "0";
return parts[0] + "." + dec;
}
// 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, 6).replace(/0+$/, "") || "0";
const dec = parts[1].replace(/0+$/, "") || "0";
return parts[0] + "." + dec;
}
@@ -149,11 +143,7 @@ async function fetchTokenBalances(address, blockscoutUrl, trackedTokens) {
const scale = known !== null ? known : decimals;
// null is a holding of an amount that cannot be stated, which is
// not the same as a holding of zero, and must never render as one.
// With a scale, the display filter proper applies: a balance that
// rounds to zero at six places is dust and is not listed. Without
// one there is no such judgement to make, and the row is kept.
const bal = scale === null ? null : formatTokenBalance(raw, scale);
if (bal === "0.0") continue;
// null means the explorer reported no count, which is not the
// same as a count of zero. This gate is not the low-holder
// display filter: it has no user-facing off switch and governs
@@ -172,6 +162,19 @@ 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
@@ -221,7 +224,9 @@ async function refreshBalances(
provider
.getBalance(addr.address)
.then((bal) => {
addr.balance = formatBalance(bal);
// Exact, never cut: a cut here stores a small nonzero
// balance as zero.
addr.balance = formatEther(bal);
log.debugf("ETH balance", addr.address, addr.balance);
})
.catch((e) => {
+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);