fix: say an unknown-scale balance the same way on Send and on confirm (closes #377)
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 is contained in:
@@ -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
|
for a bundled or tracked token the explorer merely omitted, which is not a
|
||||||
refusal the wallet has any reason to make.
|
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
|
**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
|
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
|
decoded swap (see TxApproval below), do not always read as a number. They can
|
||||||
@@ -1395,7 +1402,9 @@ view would leave a wallet one click from deletion.
|
|||||||
- What to send: token dropdown (or static display with contract address when
|
- What to send: token dropdown (or static display with contract address when
|
||||||
locked from AddressToken)
|
locked from AddressToken)
|
||||||
- To: address or ENS name input, with an inline validation message
|
- 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
|
- "Review" button, disabled until the recipient validates
|
||||||
- **Transitions**:
|
- **Transitions**:
|
||||||
- "Review" (valid inputs, ENS resolved) → **ConfirmTx**
|
- "Review" (valid inputs, ENS resolved) → **ConfirmTx**
|
||||||
@@ -1413,7 +1422,8 @@ view would leave a wallet one click from deletion.
|
|||||||
- From: blockie + color dot + full address + etherscan link + wallet title
|
- From: blockie + color dot + full address + etherscan link + wallet title
|
||||||
- To: blockie + color dot + full address + etherscan link + ENS name
|
- To: blockie + color dot + full address + etherscan link + ENS name
|
||||||
- Amount: value + symbol (USD in parentheses)
|
- 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",
|
- Network fee: "Estimating..." then two lines, or "Unable to estimate",
|
||||||
fetched async. The first line is what the transfer is expected to cost,
|
fetched async. The first line is what the transfer is expected to cost,
|
||||||
`gasLimit * gasPrice` (USD in parentheses); the second is the
|
`gasLimit * gasPrice` (USD in parentheses); the second is the
|
||||||
@@ -1429,7 +1439,11 @@ view would leave a wallet one click from deletion.
|
|||||||
amount plus the fee exceeds the balance (ETH transfers), not enough ETH to
|
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
|
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
|
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
|
- Password: an inline field on this screen, not a modal, with its own error
|
||||||
line
|
line
|
||||||
- "Sign & Send" button (disabled if errors, and while the network fee
|
- "Sign & Send" button (disabled if errors, and while the network fee
|
||||||
|
|||||||
@@ -45,6 +45,18 @@ but the review is broader than any of them.
|
|||||||
|
|
||||||
# Completed Steps
|
# 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 transaction with no `to` says "This transaction creates a new
|
- 2026-10-04: A transaction with no `to` says "This transaction creates a new
|
||||||
contract. It has no recipient." on its recipient line and in its transaction
|
contract. It has no recipient." on its recipient line and in its transaction
|
||||||
history row ([#250](https://git.eeqj.de/sneak/AutistMask/issues/250)). The
|
history row ([#250](https://git.eeqj.de/sneak/AutistMask/issues/250)). The
|
||||||
|
|||||||
@@ -698,15 +698,13 @@
|
|||||||
You do not have enough ETH to pay the network fee for this
|
You do not have enough ETH to pay the network fee for this
|
||||||
transfer. Please add ETH to this address and try again.
|
transfer. Please add ETH to this address and try again.
|
||||||
</div>
|
</div>
|
||||||
|
<!-- Its sentence names why the fee could not be estimated,
|
||||||
|
so show() in confirmTx.js sets it. -->
|
||||||
<div
|
<div
|
||||||
id="confirm-fee-unknown-error"
|
id="confirm-fee-unknown-error"
|
||||||
class="mb-2 border border-border border-dashed p-2 text-xs"
|
class="mb-2 border border-border border-dashed p-2 text-xs"
|
||||||
style="visibility: hidden"
|
style="visibility: hidden"
|
||||||
>
|
></div>
|
||||||
The network fee could not be estimated, so this transaction
|
|
||||||
cannot be checked against your balance. Please go back and
|
|
||||||
try again.
|
|
||||||
</div>
|
|
||||||
<div class="mb-2">
|
<div class="mb-2">
|
||||||
<label class="block mb-1 text-xs">Password</label>
|
<label class="block mb-1 text-xs">Password</label>
|
||||||
<input
|
<input
|
||||||
|
|||||||
@@ -198,6 +198,19 @@ function show(txInfo) {
|
|||||||
$("confirm-amount-fee-error").classList.toggle("hidden", isErc20);
|
$("confirm-amount-fee-error").classList.toggle("hidden", isErc20);
|
||||||
$("confirm-gas-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);
|
renderValidation(txInfo);
|
||||||
|
|
||||||
// Reset password field and error
|
// Reset password field and error
|
||||||
@@ -244,7 +257,8 @@ function renderValidation(txInfo) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Messages carrying the user's own numbers are built here; the fixed
|
// 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 = [];
|
const messages = [];
|
||||||
if (codes.includes(CODES.AMOUNT_INVALID)) {
|
if (codes.includes(CODES.AMOUNT_INVALID)) {
|
||||||
messages.push("Please enter a valid amount to send.");
|
messages.push("Please enter a valid amount to send.");
|
||||||
|
|||||||
+52
-52
@@ -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() {
|
function updateSendBalance() {
|
||||||
const addr = currentAddress();
|
const addr = currentAddress();
|
||||||
if (!addr) return;
|
if (!addr) return;
|
||||||
@@ -162,18 +206,16 @@ function updateSendBalance() {
|
|||||||
truncateAmountNeverZero(addr.balance || "0") +
|
truncateAmountNeverZero(addr.balance || "0") +
|
||||||
" ETH";
|
" ETH";
|
||||||
} else {
|
} else {
|
||||||
const tb = (addr.tokenBalances || []).find(
|
|
||||||
(t) => t.address.toLowerCase() === token.toLowerCase(),
|
|
||||||
);
|
|
||||||
const symbol = resolveSymbol(
|
const symbol = resolveSymbol(
|
||||||
token,
|
token,
|
||||||
addr.tokenBalances,
|
addr.tokenBalances,
|
||||||
state.trackedTokens,
|
state.trackedTokens,
|
||||||
);
|
);
|
||||||
// A null balance is a holding whose scale nothing knows. Saying "0"
|
// A null balance is a holding whose scale is unknown. Saying a figure
|
||||||
// for it would be a claim about the amount; the send itself is
|
// 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.
|
// refused later by transferAmountUnits() for the same missing scale.
|
||||||
const bal = tb ? tb.balance : "0";
|
const bal = tokenBalanceAndDecimals(addr, token).tokenBalance;
|
||||||
$("send-balance").textContent =
|
$("send-balance").textContent =
|
||||||
bal == null
|
bal == null
|
||||||
? "Current balance: unknown (" + symbol + ")"
|
? "Current balance: unknown (" + symbol + ")"
|
||||||
@@ -240,59 +282,17 @@ function init(_ctx) {
|
|||||||
|
|
||||||
let tokenSymbol = null;
|
let tokenSymbol = null;
|
||||||
let tokenBalance = 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;
|
let tokenDecimals = null;
|
||||||
if (token !== "ETH") {
|
if (token !== "ETH") {
|
||||||
const tb = (addr.tokenBalances || []).find(
|
|
||||||
(t) => t.address.toLowerCase() === token.toLowerCase(),
|
|
||||||
);
|
|
||||||
tokenSymbol = resolveSymbol(
|
tokenSymbol = resolveSymbol(
|
||||||
token,
|
token,
|
||||||
addr.tokenBalances,
|
addr.tokenBalances,
|
||||||
state.trackedTokens,
|
state.trackedTokens,
|
||||||
);
|
);
|
||||||
// null carried through rather than flattened to "0": the confirm
|
({ tokenBalance, tokenDecimals } = tokenBalanceAndDecimals(
|
||||||
// screen states an unknown balance as unknown, and
|
addr,
|
||||||
// validateTransfer() treats it as no balance to spend from, which
|
token,
|
||||||
// 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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
ctx.showConfirmTx({
|
ctx.showConfirmTx({
|
||||||
|
|||||||
@@ -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 () => {
|
test("while agreeing rows leave the scale usable", async () => {
|
||||||
await fetchOntoBoth([novel("6", 5000000n)], [novel("6", 5000000n)]);
|
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");
|
const txInfo = await reviewSend(NOVEL, "1.5");
|
||||||
expect(txInfo.tokenDecimals).toBe(6);
|
expect(txInfo.tokenDecimals).toBe(6);
|
||||||
expect(txInfo.tokenBalance).toBe("5.0");
|
expect(txInfo.tokenBalance).toBe("5.0");
|
||||||
|
|||||||
Reference in New Issue
Block a user