diff --git a/README.md b/README.md
index aecdfed..90a7de1 100644
--- a/README.md
+++ b/README.md
@@ -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
diff --git a/TODO.md b/TODO.md
index 00cba20..b7734b1 100644
--- a/TODO.md
+++ b/TODO.md
@@ -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
diff --git a/src/popup/index.html b/src/popup/index.html
index 554f6be..0f14a74 100644
--- a/src/popup/index.html
+++ b/src/popup/index.html
@@ -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.
+
- The network fee could not be estimated, so this transaction
- cannot be checked against your balance. Please go back and
- try again.
-
+ >
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({
diff --git a/tests/unknownScaleSend.test.js b/tests/unknownScaleSend.test.js
index 2c326d7..e264584 100644
--- a/tests/unknownScaleSend.test.js
+++ b/tests/unknownScaleSend.test.js
@@ -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");