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
+6 -8
View File
@@ -8,6 +8,7 @@ const {
renderAddressHtml,
attachCopyHandlers,
onViewLeave,
formatFee,
} = require("./helpers");
const { state, saveState } = require("../../shared/state");
const { networkByChainId } = require("../../shared/networks");
@@ -207,7 +208,7 @@ function showPhishingWarning(elementId, isPhishing) {
// and the nonce. The background compares every one of them against the signed
// artifact, so every one of them has to be on the screen — a number that is
// verified but never displayed is verified against nothing the user agreed to.
function showTxFee(approvedTx, ethPrice) {
function showTxFee(approvedTx) {
const network = networkByChainId(approvedTx.chainId);
$("approve-tx-network").textContent = network
? network.name
@@ -215,12 +216,9 @@ function showTxFee(approvedTx, ethPrice) {
const gasLimit = BigInt(approvedTx.gasLimit);
const feePerGas = BigInt(approvedTx.maxFeePerGas || approvedTx.gasPrice);
const maxFeeEth = formatTxValue(formatEther(gasLimit * feePerGas));
const usdStr = formatUsd(
ethPrice ? parseFloat(maxFeeEth) * ethPrice : null,
);
$("approve-tx-fee").textContent =
maxFeeEth + " ETH" + (usdStr ? " (" + usdStr + ")" : "");
// Through formatFee(), as the confirmation screen's fee is, so the same
// fee reads the same on both.
$("approve-tx-fee").textContent = formatFee(gasLimit * feePerGas);
let detail =
gasLimit.toString() +
@@ -332,7 +330,7 @@ function showTxApproval(details) {
$("approve-tx-value").textContent =
ethValueFormatted + " ETH" + (usdStr ? " (" + usdStr + ")" : "");
showTxFee(approvedTx, ethPrice);
showTxFee(approvedTx);
// Decode calldata (reuse decoded from above)
const decodedEl = $("approve-tx-decoded");
+21 -25
View File
@@ -15,6 +15,7 @@ const {
attachCopyHandlers,
goBack,
onViewLeave,
formatFee,
} = require("./helpers");
const { state } = require("../../shared/state");
const { getSignerForAddress } = require("../../shared/wallet");
@@ -31,6 +32,9 @@ const {
transferAmountUnits,
} = require("../../shared/transferAmount");
const { assertWithinCeilings } = require("../../shared/approvalVerify");
// The balance lines, the fee reserve and the insufficient-balance messages go
// through it, as the approval screen's amounts do.
const { truncateAmountNeverZero } = require("../../shared/amountDisplay");
const {
CODES,
FEE_PENDING,
@@ -150,11 +154,17 @@ function show(txInfo) {
$("confirm-balance").textContent =
bal == null
? "unknown (" + symbol + ")"
: valueWithUsd(bal + " " + symbol, balUsd);
: valueWithUsd(
truncateAmountNeverZero(bal) + " " + symbol,
balUsd,
);
} else {
const bal = txInfo.balance || "0";
const balUsd = ethPrice ? parseFloat(bal) * ethPrice : null;
$("confirm-balance").textContent = valueWithUsd(bal + " ETH", balUsd);
$("confirm-balance").textContent = valueWithUsd(
truncateAmountNeverZero(bal) + " ETH",
balUsd,
);
}
// Check for warnings (synchronous local checks)
@@ -249,7 +259,7 @@ function renderValidation(txInfo) {
: "Insufficient " +
symbol +
" balance. You have " +
txInfo.tokenBalance +
truncateAmountNeverZero(txInfo.tokenBalance) +
" " +
symbol +
" but are trying to send " +
@@ -262,7 +272,7 @@ function renderValidation(txInfo) {
if (codes.includes(CODES.INSUFFICIENT_ETH)) {
messages.push(
"Insufficient balance. You have " +
txInfo.balance +
truncateAmountNeverZero(txInfo.balance || "0") +
" ETH but are trying to send " +
txInfo.amount +
" ETH.",
@@ -305,14 +315,6 @@ function setVisible(id, visible) {
$(id).style.visibility = visible ? "visible" : "hidden";
}
// A fee in wei as an ETH string, truncated to 6 decimal places.
function formatFeeEth(wei) {
const parts = formatEther(wei).split(".");
const dec =
parts.length > 1 ? parts[1].slice(0, 6).replace(/0+$/, "") || "0" : "0";
return parts[0] + "." + dec + " ETH";
}
async function estimateGas(txInfo) {
try {
const provider = getProvider(state.rpcUrl, state.networkId);
@@ -359,26 +361,20 @@ async function estimateGas(txInfo) {
// flight; a stale fee must not reach the screen or the balance check.
if (pendingTx !== txInfo) return;
const ethPrice = getPrice("ETH");
const usd = (wei) =>
ethPrice ? parseFloat(formatEther(wei)) * ethPrice : null;
// 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 = valueWithUsd(
"~" + formatFeeEth(estimateWei),
usd(estimateWei),
);
$("confirm-fee-amount").textContent = "~" + formatFee(estimateWei);
$("confirm-fee-reserve").textContent =
"up to " + formatFeeEth(gasCostWei) + " reserved";
"up to " +
truncateAmountNeverZero(formatEther(gasCostWei)) +
" ETH reserved";
setVisible("confirm-fee-reserve", true);
} else {
// No spread to report: either there is no estimate, or the node
// quotes a gas price at or above maxFeePerGas, so the expected
// cost is not below the reserve. Show the reserve alone.
$("confirm-fee-amount").textContent = valueWithUsd(
formatFeeEth(gasCostWei),
usd(gasCostWei),
);
$("confirm-fee-amount").textContent = formatFee(gasCostWei);
setVisible("confirm-fee-reserve", false);
}
feeStatus = FEE_KNOWN;
+16
View File
@@ -12,6 +12,8 @@
// escapeHtml lives in src/shared/html.js, where the escape and the
// reasoning behind it are; it is re-exported below so views keep importing
// it from here.
const { formatEther } = require("ethers");
const { truncateAmountNeverZero } = require("../../shared/amountDisplay");
const { DEBUG } = require("../../shared/constants");
const { escapeHtml } = require("../../shared/html");
const { isDebug } = require("../../shared/log");
@@ -243,6 +245,19 @@ function unknownableAmount(balance) {
return Number.isFinite(n) ? n : null;
}
// 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.
function formatFee(wei) {
const eth = formatEther(wei);
const ethPrice = getPrice("ETH");
const usd = ethPrice ? formatUsd(parseFloat(eth) * ethPrice) : "";
return (
truncateAmountNeverZero(eth) + " ETH" + (usd ? " (" + usd + ")" : "")
);
}
// One row of the balance list: symbol, quantity, fiat value.
//
// `symbol` is the ERC-20's own symbol() as the block explorer reported it,
@@ -610,6 +625,7 @@ module.exports = {
balanceLinesForAddress,
addressHoldsFunds,
unknownableAmount,
formatFee,
addressColor,
addressDotHtml,
escapeHtml,
+8 -2
View File
@@ -16,6 +16,7 @@ const { resolveTokenDecimals } = require("../../shared/approvalAmount");
const { resolveSymbol } = require("../../shared/tokenList");
const { isLowHolderCount } = require("../../shared/holders");
const { isSpoofedSymbol } = require("../../shared/symbolSpoof");
const { truncateAmountNeverZero } = require("../../shared/amountDisplay");
const { getAddress } = require("ethers");
const ZERO_ADDRESS = "0x0000000000000000000000000000000000000000";
@@ -150,7 +151,9 @@ function updateSendBalance() {
const token = state.selectedToken || $("send-token").value;
if (token === "ETH") {
$("send-balance").textContent =
"Current balance: " + (addr.balance || "0") + " ETH";
"Current balance: " +
truncateAmountNeverZero(addr.balance || "0") +
" ETH";
} else {
const tb = (addr.tokenBalances || []).find(
(t) => t.address.toLowerCase() === token.toLowerCase(),
@@ -167,7 +170,10 @@ function updateSendBalance() {
$("send-balance").textContent =
bal == null
? "Current balance: unknown (" + symbol + ")"
: "Current balance: " + bal + " " + symbol;
: "Current balance: " +
truncateAmountNeverZero(bal) +
" " +
symbol;
}
}