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. + + >
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");