fix: say an unknown-scale balance the same way on Send and on confirm (closes #377)
check / check (push) Waiting to run
e2e / e2e-chrome (push) Waiting to run
e2e / e2e-firefox (push) Waiting to run

When two addresses' explorer reports disagree on a token's decimals, the
Send screen showed the stored figure while the confirmation screen said
the balance was unknown. One function in send.js now gives both the
balance and scale, so both read `unknown (SYMBOL)`.

The confirmation screen's fee-unknown message names its cause: for an
unknown scale it says the wallet does not know the token's decimal
places and the transaction cannot be sent, instead of asking for a
retry that cannot help. Other causes keep the old sentence.

Model: opus-5-5
This commit was merged in pull request #425.
This commit is contained in:
2026-10-04 15:59:15 +02:00
parent a68f30c480
commit 45f11ee920
6 changed files with 147 additions and 61 deletions
+17 -3
View File
@@ -949,6 +949,13 @@ 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.
The Send screen also consults every address's explorer reports, so a contract
two addresses report different `decimals` for has no scale there, and the stored
balance, formatted at one of those scales, is withdrawn with it. The Send
screen's `Current balance` and the confirmation screen's balance line then both
read `unknown (SYMBOL)`. The balance list formats each explorer row as it is
fetched, without that cross-address check, and shows the row's figure.
**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
@@ -1409,7 +1416,9 @@ view would leave a wallet one click from deletion.
- What to send: token dropdown (or static display with contract address when
locked from AddressToken)
- To: address or ENS name input, with an inline validation message
- Amount input with current balance display
- Amount input with current balance display, which reads
`Current balance: unknown (SYMBOL)` for a token whose scale is unknown, as
ConfirmTx's balance line does (see Unknown token scale)
- "Review" button, disabled until the recipient validates
- **Transitions**:
- "Review" (valid inputs, ENS resolved) → **ConfirmTx**
@@ -1427,7 +1436,8 @@ view would leave a wallet one click from deletion.
- From: blockie + color dot + full address + etherscan link + wallet title
- To: blockie + color dot + full address + etherscan link + ENS name
- Amount: value + symbol (USD in parentheses)
- Your balance: value + symbol (USD in parentheses)
- Your balance: value + symbol (USD in parentheses), or `unknown (SYMBOL)`
for a token whose scale is unknown
- Network fee: "Estimating..." then two lines, or "Unable to estimate",
fetched async. The first line is what the transfer is expected to cost,
`gasLimit * gasPrice` (USD in parentheses); the second is the
@@ -1443,7 +1453,11 @@ view would leave a wallet one click from deletion.
amount plus the fee exceeds the balance (ETH transfers), not enough ETH to
pay the fee for the transfer (ERC-20 transfers), and the fee could not be
estimated. The first two are mutually exclusive per transfer type, so only
the applicable one holds space
the applicable one holds space. The last names its cause: for a token
whose scale is unknown the fee can never be estimated, and it says the
wallet does not know how many decimal places the token uses and that the
transaction cannot be sent; for any other failure it asks the user to go
back and try again
- Password: an inline field on this screen, not a modal, with its own error
line
- "Sign & Send" button (disabled if errors, and while the network fee
+12
View File
@@ -45,6 +45,18 @@ but the review is broader than any of them.
# Completed Steps
- 2026-10-04: A token whose scale is unknown reads the same on the Send screen
as on the confirmation screen
([#377](https://git.eeqj.de/sneak/AutistMask/issues/377)). When two addresses'
explorer reports disagree on a token's `decimals`, the Send screen showed the
stored figure while the confirmation screen it leads to said
`unknown (SYMBOL)`; both now say `unknown (SYMBOL)`, from one function in
`src/popup/views/send.js`. The confirmation screen's fee-unknown message names
its cause: for an unknown scale it says the wallet does not know how many
decimal places the token uses and that the transaction cannot be sent, instead
of asking the user to go back and try again, which cannot help. For any other
cause it is unchanged.
- 2026-10-04: A Uniswap V2 exact-out swap (Universal Router command `0x09`) is
decoded on the approval screen
([#283](https://git.eeqj.de/sneak/AutistMask/issues/283)). `decode()` in
+3 -5
View File
@@ -698,15 +698,13 @@
You do not have enough ETH to pay the network fee for this
transfer. Please add ETH to this address and try again.
</div>
<!-- Its sentence names why the fee could not be estimated,
so show() in confirmTx.js sets it. -->
<div
id="confirm-fee-unknown-error"
class="mb-2 border border-border border-dashed p-2 text-xs"
style="visibility: hidden"
>
The network fee could not be estimated, so this transaction
cannot be checked against your balance. Please go back and
try again.
</div>
></div>
<div class="mb-2">
<label class="block mb-1 text-xs">Password</label>
<input
+15 -1
View File
@@ -198,6 +198,19 @@ function show(txInfo) {
$("confirm-amount-fee-error").classList.toggle("hidden", isErc20);
$("confirm-gas-error").classList.toggle("hidden", !isErc20);
// The fee-unknown message names its cause, which is also known here.
// Without the token's scale estimateGas() cannot encode the transfer, so
// the estimate fails every time and going back cannot help; any other
// failure may clear on a retry.
$("confirm-fee-unknown-error").textContent =
isErc20 && txInfo.tokenDecimals == null
? "The network fee could not be estimated, because this wallet" +
" does not know how many decimal places this token uses, so" +
" this transaction cannot be sent."
: "The network fee could not be estimated, so this transaction" +
" cannot be checked against your balance. Please go back and" +
" try again.";
renderValidation(txInfo);
// Reset password field and error
@@ -244,7 +257,8 @@ function renderValidation(txInfo) {
});
// Messages carrying the user's own numbers are built here; the fixed
// sentences live in the reserved elements in index.html.
// sentences live in the reserved elements in index.html, except the
// fee-unknown one, which show() sets.
const messages = [];
if (codes.includes(CODES.AMOUNT_INVALID)) {
messages.push("Please enter a valid amount to send.");
+52 -52
View File
@@ -146,6 +146,50 @@ function renderSendTokenSelect(addr) {
}
}
// The token balance and scale the Send screen states and hands the
// confirmation screen, so the two screens describe the holding the same way.
//
// The scale is resolved the same way balances.js resolved the scale it
// DISPLAYED this token's balance at: bundled list, then the user's tracked
// tokens, then the explorer. The stored tokenBalances[].decimals is the
// explorer's own answer alone, so reading it raw carries a null forward for a
// token the wallet does know the scale of — and displayedDecimals() then throws
// inside estimateGas(), which the confirmation screen reports as an unestimable
// fee. Unsendable, over a scale that was never in doubt
// (https://git.eeqj.de/sneak/AutistMask/issues/349). Still null when nothing
// knows: no fallback.
//
// Resolved WITH `wallets`, which balances.js does not pass: that adds
// explorerDecimals()'s cross-address check, so a contract two addresses report
// different scales for answers null rather than picking one. That check has to
// apply here, because this scale encodes the transfer — it is carried forward
// so the transfer is encoded with the number the user read rather than with
// whatever the contract answers at signing time (see
// src/shared/transferAmount.js). balances.js is formatting one explorer row at
// fetch time and cannot consult a state it is in the middle of replacing.
//
// The two resolutions can therefore differ, and where they do, the stored
// `balance` is a quantity computed at a scale this screen has just declined to
// stand behind. Stating it would leave validateTransfer() checking the amount
// against a number the wallet does not vouch for, so it is withdrawn: unknown
// scale means unknown balance. It is null rather than "0": both screens state
// an unknown balance as unknown, and validateTransfer() treats it as no balance
// to spend from, which is the fail-closed side of an amount nobody can check.
// Only a stored quantity is withdrawn: the "0" for a token that has no row at
// all is an absence of holdings, which is true at every scale.
function tokenBalanceAndDecimals(addr, token) {
const tb = (addr.tokenBalances || []).find(
(t) => t.address.toLowerCase() === token.toLowerCase(),
);
const tokenDecimals = resolveTokenDecimals(token, {
trackedTokens: state.trackedTokens,
wallets: state.wallets,
});
if (!tb) return { tokenBalance: "0", tokenDecimals };
if (tokenDecimals === null) return { tokenBalance: null, tokenDecimals };
return { tokenBalance: tb.balance ?? null, tokenDecimals };
}
function updateSendBalance() {
const addr = currentAddress();
if (!addr) return;
@@ -162,18 +206,16 @@ function updateSendBalance() {
truncateAmountNeverZero(addr.balance || "0") +
" ETH";
} else {
const tb = (addr.tokenBalances || []).find(
(t) => t.address.toLowerCase() === token.toLowerCase(),
);
const symbol = resolveSymbol(
token,
addr.tokenBalances,
state.trackedTokens,
);
// A null balance is a holding whose scale nothing knows. Saying "0"
// for it would be a claim about the amount; the send itself is
// A null balance is a holding whose scale is unknown. Saying a figure
// for it would be a claim about the amount, so it reads as the
// confirmation screen's balance line reads it; the send itself is
// refused later by transferAmountUnits() for the same missing scale.
const bal = tb ? tb.balance : "0";
const bal = tokenBalanceAndDecimals(addr, token).tokenBalance;
$("send-balance").textContent =
bal == null
? "Current balance: unknown (" + symbol + ")"
@@ -240,59 +282,17 @@ function init(_ctx) {
let tokenSymbol = null;
let tokenBalance = null;
// The scale the amount and the balance below are rendered at, carried
// forward so the transfer is encoded with the number the user read
// rather than with whatever the contract answers at signing time. See
// src/shared/transferAmount.js.
let tokenDecimals = null;
if (token !== "ETH") {
const tb = (addr.tokenBalances || []).find(
(t) => t.address.toLowerCase() === token.toLowerCase(),
);
tokenSymbol = resolveSymbol(
token,
addr.tokenBalances,
state.trackedTokens,
);
// null carried through rather than flattened to "0": the confirm
// screen states an unknown balance as unknown, and
// validateTransfer() treats it as no balance to spend from, which
// is the fail-closed side of an amount nobody can check.
tokenBalance = tb ? (tb.balance ?? null) : "0";
// Resolved the same way balances.js resolved the scale it
// DISPLAYED this token's balance at: bundled list, then the user's
// tracked tokens, then the explorer. The stored
// tokenBalances[].decimals is the explorer's own answer alone, so
// reading it raw carries a null forward for a token the wallet
// does know the scale of — and displayedDecimals() then throws
// inside estimateGas(), which the confirmation screen reports as
// an unestimable fee. Unsendable, over a scale that was never in
// doubt (https://git.eeqj.de/sneak/AutistMask/issues/349).
// Still null when nothing knows: no fallback.
//
// Resolved WITH `wallets`, which balances.js does not pass: that
// adds explorerDecimals()'s cross-address check, so a contract two
// addresses report different scales for answers null rather than
// picking one. That check has to apply here, because this value
// encodes a transfer; balances.js is formatting one explorer row
// at fetch time and cannot consult a state it is in the middle of
// replacing.
tokenDecimals = resolveTokenDecimals(token, {
trackedTokens: state.trackedTokens,
wallets: state.wallets,
});
// The two resolutions can therefore differ, and where they do, the
// stored `balance` is a quantity computed at a scale this screen
// has just declined to stand behind. Stating it would leave
// validateTransfer() checking the amount against a number the
// wallet does not vouch for, and — since the unknown-balance path
// is gated on the balance, not on the scale — would leave the
// fee-estimate failure as the only thing on the confirmation
// screen, which says nothing about decimals. Unknown scale means
// unknown balance. Only a stored quantity is withdrawn: the "0"
// for a token that has no row at all is an absence of holdings,
// which is true at every scale.
if (tb && tokenDecimals === null) tokenBalance = null;
({ tokenBalance, tokenDecimals } = tokenBalanceAndDecimals(
addr,
token,
));
}
ctx.showConfirmTx({
+48
View File
@@ -382,8 +382,56 @@ describe("a scale the explorer's own rows disagree about", () => {
);
});
// https://git.eeqj.de/sneak/AutistMask/issues/377. The Send screen read the
// stored balance and said "5.0000 NOVEL", the confirmation screen it leads
// to said "unknown (NOVEL)", and the fee message asked the user to go back
// and try again, which cannot supply a scale.
test("reads the same on the Send screen and the confirmation screen, and the fee message names the scale", async () => {
await fetchOntoBoth([novel("6", 5000000n)], [novel("18", FIVE_WETH)]);
state.selectedToken = NOVEL;
send.updateSendBalance();
expect(text("send-balance")).toBe("Current balance: unknown (NOVEL)");
const txInfo = await reviewSend(NOVEL, "1.5");
confirmTx.show(txInfo);
await settle();
expect(text("confirm-balance")).toBe("unknown (NOVEL)");
expect(text("confirm-fee-unknown-error")).toBe(
"The network fee could not be estimated, because this wallet" +
" does not know how many decimal places this token uses, so" +
" this transaction cannot be sent.",
);
expect(el("confirm-fee-unknown-error").style.visibility).toBe(
"visible",
);
});
test("while a fee that fails for any other reason keeps its retry", async () => {
await fetchOntoBoth([novel("6", 5000000n)], [novel("6", 5000000n)]);
const txInfo = await reviewSend(NOVEL, "1.5");
const getFeeData = mockProvider.getFeeData;
mockProvider.getFeeData = async () => {
throw new Error("the node did not answer");
};
try {
confirmTx.show(txInfo);
await settle();
} finally {
mockProvider.getFeeData = getFeeData;
}
expect(text("confirm-fee-amount")).toBe("Unable to estimate");
expect(text("confirm-fee-unknown-error")).toBe(
"The network fee could not be estimated, so this transaction" +
" cannot be checked against your balance. Please go back and" +
" try again.",
);
});
test("while agreeing rows leave the scale usable", async () => {
await fetchOntoBoth([novel("6", 5000000n)], [novel("6", 5000000n)]);
state.selectedToken = NOVEL;
send.updateSendBalance();
expect(text("send-balance")).toBe("Current balance: 5.0000 NOVEL");
const txInfo = await reviewSend(NOVEL, "1.5");
expect(txInfo.tokenDecimals).toBe(6);
expect(txInfo.tokenBalance).toBe("5.0");