Compare commits

..
1 Commits
Author SHA1 Message Date
clawbot 1df0ebb493 fix: show balances and fees below 0.000001 ETH as nonzero on the send screens (closes #343)
check / check (push) Successful in 2m53s
e2e / e2e-chrome (push) Successful in 4m35s
e2e / e2e-firefox (push) Successful in 3m46s
The stored ETH balance and the send-confirm screen's fee were each cut to six
decimal places, so a value below 0.000001 read as 0.0, and the fee no longer
matched the approval screen's. The ETH balance is now stored exactly, and the
send screen's Current balance and the send-confirm screen's balance, fee,
reserve and insufficient-balance messages go through truncateAmountNeverZero(),
as the approval screen does. Token balances keep their six-decimal value: it is
also what leaves dust off the balance list, and keeps the string within the 18
decimals the balance check reads.

Model: opus-5-5
2026-10-04 01:22:45 +00:00
7 changed files with 50 additions and 122 deletions
+10 -51
View File
@@ -882,12 +882,9 @@ On those screens, when the truncated string would contain no digit from 1 to 9
and the value does, the amount is extended to its first significant digit
instead: `0.000000000000000001 DAI`, not `0.0000 DAI`. The test is on the whole
truncated string, integer part included, so `1.00005` still shows as `1.0000` —
the exception only fires where the entire displayed figure would read as zero.
Truncation stays truncation: `0.99999` shows as `0.9999`, never rounded up. The
rule still renders a genuine zero as `0.0000`. Two lines of a swap say a zero in
words instead: `Min. received` reads `None (no minimum guaranteed)` for a zero
minimum, and `Amount` reads `All available (V4 open delta)` when the amount it
shows is a V4 exact-in `amountIn` of zero.
the exception only fires where the entire displayed figure would read as zero. A
genuine zero still renders `0.0000`, and truncation stays truncation: `0.99999`
shows as `0.9999`, never rounded up.
The rule and its exception live in `src/shared/amountDisplay.js` as
`truncateAmount()` and `truncateAmountNeverZero()`. Everything the approval and
@@ -896,16 +893,13 @@ the ETH value and max fee (`src/popup/views/approval.js`), the swap's `Amount`
and `Min. received` lines (`src/shared/uniswap.js`), the Send screen's
`Current balance` (`src/popup/views/send.js`), and the balance and network fee
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. The ETH balance is stored exactly, so a balance below the floor
reaches these screens as it is. A token balance is stored to six decimal places,
and a holding below 0.000001 is not listed at all. The history and balance lists
(`src/shared/transactions.js`) use the unfloored one: the transaction detail
view is the authoritative record and already shows exact precision. The
4-decimal rule is unchanged everywhere else, including for amounts at or above
the floor on the approval screens.
(`src/popup/views/confirmTx.js`), so a fee reads the same there as on the
approval screen. For this the ETH balance is stored exactly. A token balance is
stored to six decimal places, and a holding below 0.000001 is not listed at all.
The history and balance lists (`src/shared/transactions.js`) use the unfloored
one: the transaction detail view is the authoritative record and already shows
exact precision. 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
@@ -948,41 +942,6 @@ and compare against. Reading the stored field directly instead answers `null`
for a bundled or tracked token the explorer merely omitted, which is not a
refusal the wallet has any reason to make.
**Decoded amount lines on the transaction approval screen:** the `Amount` line
of a decoded ERC-20 call, and the `Amount` and `Min. received` lines of a
decoded swap (see TxApproval below), do not always read as a number. They can
read:
- A formatted quantity, e.g. `17.1900 USDT`, when the token's scale is known:
truncated to four decimals, with the floor and the zero cases described above.
- `<amount> base units (decimals unknown)` when the scale is unknown: the
base-unit integer, rather than a figure at a guessed scale (see Unknown token
scale above).
- `Unlimited`: on the ERC-20 `Amount` line, an `approve` of the `uint256`
maximum, an unbounded allowance. On the swap's `Amount` line, any amount at or
above the `uint160` maximum, whichever step set the line: a `PERMIT2_PERMIT`
amount at that maximum, which is an unbounded permit, or a V2 or V3 exact-in
or `WRAP_ETH` amount that large, which is not an allowance. The router's
whole-balance value, `CONTRACT_BALANCE` (`2^255`), is one such amount.
- `All available (V4 open delta)`: the swap's `Amount` line, when the amount it
shows is a V4 exact-in `amountIn` of zero. V4 reads that zero as "use the
whole open delta", so the calldata states no quantity. The line shows the
amount of one step that names an input token or amount: the last
`PERMIT2_PERMIT` step if there is one, otherwise the first V2 or V3 exact-in,
`WRAP_ETH` or V4 swap step, a V4 swap step giving the `amountIn` of its first
readable exact-in action.
- `None (no minimum guaranteed)`: the swap's `Min. received` line, when the
minimum it shows is zero, whether a V2, V3 or V4 swap's minimum or a
`BALANCE_CHECK_ERC20` step's `minBalance`. Before
[#359](https://git.eeqj.de/sneak/AutistMask/issues/359), a zero `minBalance`
read `0.0000` when the token's scale was known.
The swap's `Token In` and `Token Out` lines name a currency, not an amount; each
reads `Unknown (not named in the calldata)` when the decoder found no token for
that side. The token permission warning on the signature screen has its own
amount wording, including `Unknown`; the SignApproval section below describes
it.
#### Partial USD totals
Prices are fetched for the top 25 tokens only, so an address can hold assets the
+4 -20
View File
@@ -53,12 +53,10 @@ but the review is broader than any of them.
stored exactly, and 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. Token balances are still stored to six decimal places: that cut is
also what leaves a holding below 0.000001 off the balance list, and keeps the
stored string within the 18 decimals the balance check reads.
approval screen already used, so the same fee reads the same on both screens.
Token balances are still stored to six decimal places: that cut is also what
leaves a holding below 0.000001 off the balance list, and keeps the stored
string within the 18 decimals the balance check reads.
- 2026-10-03: The typed-data signing screen warns for a token permission, and
names the primary type ethers signs
([#400](https://git.eeqj.de/sneak/AutistMask/issues/400)). A Permit or Permit2
@@ -187,20 +185,6 @@ but the review is broader than any of them.
standalone storage entry, never part of the versioned `autistmask` profile, so
the state schema is untouched and no existing profile is affected.
- 2026-09-21: `README.md` no longer says a genuine zero always renders `0.0000`
([#369](https://git.eeqj.de/sneak/AutistMask/issues/369)). The amount rule
still renders one as `0.0000`, but two swap lines say a zero in words instead:
`Min. received` reads `None (no minimum guaranteed)` for a zero minimum, and
`Amount` reads `All available (V4 open delta)` when the amount it shows is a
V4 exact-in `amountIn` of zero. A new list says what the decoded ERC-20 and
swap amount lines on the transaction approval screen can read: a formatted
quantity, base units with decimals unknown, `Unlimited`,
`All available (V4 open delta)` and `None (no minimum guaranteed)`, which a
zero `minBalance` on a `BALANCE_CHECK_ERC20` step now reads instead of
`0.0000` at a known scale. The README also names
`Unknown (not named in the calldata)` on the swap's token lines, and points to
SignApproval for the token permission warning's own wording. Docs only.
- 2026-08-30: An address no longer wraps, or is shortened to fit, in any of the
common views ([#380](https://git.eeqj.de/sneak/AutistMask/issues/380)). The
wallet list was the reported case: the address shared one row with the
+8 -6
View File
@@ -8,7 +8,6 @@ const {
renderAddressHtml,
attachCopyHandlers,
onViewLeave,
formatFee,
} = require("./helpers");
const { state, saveState } = require("../../shared/state");
const { networkByChainId } = require("../../shared/networks");
@@ -208,7 +207,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) {
function showTxFee(approvedTx, ethPrice) {
const network = networkByChainId(approvedTx.chainId);
$("approve-tx-network").textContent = network
? network.name
@@ -216,9 +215,12 @@ function showTxFee(approvedTx) {
const gasLimit = BigInt(approvedTx.gasLimit);
const feePerGas = BigInt(approvedTx.maxFeePerGas || approvedTx.gasPrice);
// 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);
const maxFeeEth = formatTxValue(formatEther(gasLimit * feePerGas));
const usdStr = formatUsd(
ethPrice ? parseFloat(maxFeeEth) * ethPrice : null,
);
$("approve-tx-fee").textContent =
maxFeeEth + " ETH" + (usdStr ? " (" + usdStr + ")" : "");
let detail =
gasLimit.toString() +
@@ -330,7 +332,7 @@ function showTxApproval(details) {
$("approve-tx-value").textContent =
ethValueFormatted + " ETH" + (usdStr ? " (" + usdStr + ")" : "");
showTxFee(approvedTx);
showTxFee(approvedTx, ethPrice);
// Decode calldata (reuse decoded from above)
const decodedEl = $("approve-tx-decoded");
+16 -7
View File
@@ -15,7 +15,6 @@ const {
attachCopyHandlers,
goBack,
onViewLeave,
formatFee,
} = require("./helpers");
const { state } = require("../../shared/state");
const { getSignerForAddress } = require("../../shared/wallet");
@@ -32,8 +31,8 @@ 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.
// Every balance and fee this screen shows goes through it, as the approval
// screen's do, so the same value reads the same on both.
const { truncateAmountNeverZero } = require("../../shared/amountDisplay");
const {
CODES,
@@ -361,10 +360,17 @@ 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
// does, so the same fee reads the same on both.
const ethPrice = getPrice("ETH");
const usd = (wei) =>
ethPrice ? parseFloat(formatEther(wei)) * ethPrice : null;
if (estimateWei !== null && estimateWei < gasCostWei) {
$("confirm-fee-amount").textContent = "~" + formatFee(estimateWei);
$("confirm-fee-amount").textContent = valueWithUsd(
"~" +
truncateAmountNeverZero(formatEther(estimateWei)) +
" ETH",
usd(estimateWei),
);
$("confirm-fee-reserve").textContent =
"up to " +
truncateAmountNeverZero(formatEther(gasCostWei)) +
@@ -374,7 +380,10 @@ async function estimateGas(txInfo) {
// 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 = formatFee(gasCostWei);
$("confirm-fee-amount").textContent = valueWithUsd(
truncateAmountNeverZero(formatEther(gasCostWei)) + " ETH",
usd(gasCostWei),
);
setVisible("confirm-fee-reserve", false);
}
feeStatus = FEE_KNOWN;
-16
View File
@@ -12,8 +12,6 @@
// 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");
@@ -245,19 +243,6 @@ 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, so it never understates what the fee costs.
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,
@@ -625,7 +610,6 @@ module.exports = {
balanceLinesForAddress,
addressHoldsFunds,
unknownableAmount,
formatFee,
addressColor,
addressDotHtml,
escapeHtml,
+5 -4
View File
@@ -1505,10 +1505,11 @@ async function backToAddress(page) {
// Drive the popup to the confirmation screen for one send.
//
// It waits for the send screen to be showing `balance`, the fixture's balance
// as that screen displays it, before filling anything in. Waiting for it —
// rather than for a refresh to have probably landed — is what keeps every
// assertion below deterministic after a fixture change.
// It waits for the send screen to be showing `balance` before filling
// anything in. That figure is the exact number the spend gate compares
// against, so waiting for it — rather than for a refresh to have probably
// landed — is what keeps every assertion below deterministic after a
// fixture change.
async function goToConfirm(page, { token, balance, amount }) {
await backToAddress(page);
await page.click("#btn-send");
+7 -18
View File
@@ -139,7 +139,6 @@ global.navigator = { clipboard: { writeText() {} } };
const { refreshBalances } = require("../src/shared/balances");
const { state } = require("../src/shared/state");
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");
@@ -257,28 +256,18 @@ describe("a fee below 0.000001 ETH never renders as zero", () => {
// The confirmation screen shows the reserve alone when the node quotes no
// cheaper estimate, and that reserve is the same gas limit times maximum fee
// per gas that the approval screen calls the max fee. An ETH price is set, as
// it is on mainnet, so the USD value has to match too.
// per gas that the approval screen calls the max fee.
describe("the same fee reads the same on the confirmation and approval screens", () => {
beforeEach(() => {
prices.ETH = 3000;
});
afterEach(() => {
clearPrices();
});
test.each([
// 21000 gas at 1 wei.
["below the floor", 1n, "0.00000000000002 ETH (< $0.01)"],
// 0.001235294117631 ETH, which is $3.71. Pricing the truncated
// 0.0012 instead would read $3.60.
["with more than four decimals", 58823529411n, "0.0012 ETH ($3.71)"],
])("%s", async (_label, feePerGas, expected) => {
["below the floor", 1n],
["with more than four decimals", 58823529411n],
])("%s", async (_label, feePerGas) => {
mockNode.feeData = { maxFeePerGas: feePerGas, gasPrice: feePerGas };
await refreshWith(10n ** 18n);
await confirmEthSend("0.1");
expect(text("confirm-fee-amount")).toBe(expected);
const onConfirm = text("confirm-fee-amount");
await approveTxWithFeePerGas(feePerGas);
expect(text("approve-tx-fee")).toBe(expected);
expect(text("approve-tx-fee")).toBe(onConfirm);
expect(onConfirm).toMatch(/[1-9]/);
});
});