diff --git a/README.md b/README.md index 868120f..504bd81 100644 --- a/README.md +++ b/README.md @@ -1025,6 +1025,8 @@ removing the address only stops the wallet tracking it. It explains the way back: delete the whole wallet in Settings and import its backup again. The scan after an import finds only addresses with on-chain activity. When the address holds anything, the screen lists the holdings and the USD total under a warning. +An ETH balance that no refresh has read counts as holding something. The warning +then reads "This address may hold a balance." "Remove Address" removes the address and its site permissions. It returns to Home with the flash message "Address removed." The wallet's `nextIndex` is not @@ -1212,13 +1214,25 @@ there in place of a zero. It does so after each of these: - an address or a wallet is added; - the network is switched. -This lasts until a refresh has read the token's balance at every address. +This lasts until a refresh in the popup has read the token's balance at every +address. + +An address's ETH balance is `null` until a refresh has read that address. This +holds for a new address, and for every address after a network switch. While it +is `null`: + +- the balance lists show "quantity unknown"; +- Home's ETH balance reads `unknown (ETH)`; +- Send reads `Current balance: unknown (ETH)`, and "Max" fills in nothing; +- "Send" is not refused for a zero balance; +- the address has no total; +- DeleteAddress shows its warning. Only a whole number from 0 to 80 counts as a token's decimals. A token's -`decimals` is stored only as a source reported it, never guessed. A `null` -balance means the decimals are unknown, never that the balance is zero. While -"Use Blockscout" is off, the bundled list and the tracked tokens may lack a -token's symbol or decimals. The approval and status screens then add a line +`decimals` is stored only as a source reported it, never guessed. A token's +`null` balance means the decimals are unknown, never that the balance is zero. +While "Use Blockscout" is off, the bundled list and the tracked tokens may lack +a token's symbol or decimals. The approval and status screens then add a line under that token saying that Blockscout's report is not used. The wallet's own ERC-20 send is encoded with the decimals the screen showed. At @@ -1254,6 +1268,7 @@ unpriced holding was left out. `formatAddressTotal()` renders that result the same way on every screen: - on Sepolia, or before the first price fetch: no total; +- while the address's ETH balance is unknown: no total; - everything priced: `Total: $5,500.00`; - part priced: `Total: $3,000.00 plus unpriced tokens`; - nothing priced but something held: `Total: unpriced tokens only`. diff --git a/src/popup/index.js b/src/popup/index.js index 90cf94e..f9c8dd9 100644 --- a/src/popup/index.js +++ b/src/popup/index.js @@ -99,12 +99,12 @@ let refreshTimer = null; // // The save also deletes `balanceNotFetched` from each tracked token whose // balance this refresh read at every address. A refresh that did not read every -// address this page now holds deletes no `balanceNotFetched`. Adding a token -// sets that field (trackToken() in src/popup/views/helpers.js). So does adding -// an address, and so does a network switch. The save takes the field off the -// matching tokens of the stored record (clearBalanceNotFetched() in -// src/shared/state.js). The rest of the stored tracked token list stays as it -// is. +// address this page now holds, and every address in the stored record, deletes +// no `balanceNotFetched`. Adding a token sets that field (trackToken() in +// src/popup/views/helpers.js). So does adding an address, and so does a network +// switch. The save takes the field off the matching tokens of the stored record +// (clearBalanceNotFetched() in src/shared/state.js). The rest of the stored +// tracked token list stays as it is. // // Once the save is done, the wallet list is drawn again. So are the balances // on AddressDetail or AddressToken when that screen is up. No other screen is @@ -151,7 +151,10 @@ async function doRefreshAndRender() { if (!stored.useBlockscout) { dropUntrackedTokens(state.wallets, stored.trackedTokens); } - const readEveryAddress = state.wallets.every((wallet) => + const readEveryAddress = [ + ...state.wallets, + ...stored.wallets, + ].every((wallet) => wallet.addresses.every((addr) => refreshed.has(addr.address)), ); if (readEveryAddress) clearBalanceNotFetched(stored, readTokens); diff --git a/src/popup/views/addWallet.js b/src/popup/views/addWallet.js index abdc485..3689e21 100644 --- a/src/popup/views/addWallet.js +++ b/src/popup/views/addWallet.js @@ -190,7 +190,7 @@ async function importMnemonic(ctx) { { address: firstAddress, index: 0, - balance: "0.0000", + balance: null, tokenBalances: [], }, ], @@ -218,7 +218,7 @@ async function importMnemonic(ctx) { wallet.addresses = scan.addresses.map((a) => ({ address: a.address, index: a.index, - balance: "0.0000", + balance: null, tokenBalances: [], })); wallet.nextIndex = scan.nextIndex; @@ -267,7 +267,7 @@ async function importPrivateKey(ctx) { type: "key", name: "Wallet " + walletNum, encryptedSecret: encrypted, - addresses: [{ address: addr, balance: "0.0000", tokenBalances: [] }], + addresses: [{ address: addr, balance: null, tokenBalances: [] }], }); markTrackedTokensNotFetched(); state.hasWallet = true; @@ -328,7 +328,7 @@ async function importXprvKey(ctx) { { address: firstAddress, index: 0, - balance: "0.0000", + balance: null, tokenBalances: [], }, ], @@ -356,7 +356,7 @@ async function importXprvKey(ctx) { wallet.addresses = scan.addresses.map((a) => ({ address: a.address, index: a.index, - balance: "0.0000", + balance: null, tokenBalances: [], })); wallet.nextIndex = scan.nextIndex; diff --git a/src/popup/views/addressDetail.js b/src/popup/views/addressDetail.js index fc02656..002f623 100644 --- a/src/popup/views/addressDetail.js +++ b/src/popup/views/addressDetail.js @@ -273,7 +273,8 @@ function init(_ctx) { state.wallets[state.selectedWallet].addresses[ state.selectedAddress ]; - if (!addr.balance || parseFloat(addr.balance) === 0) { + // A null balance is one no refresh has read, so it is not refused. + if (parseFloat(addr.balance) === 0) { showFlash("Cannot send \u2014 zero balance."); return; } diff --git a/src/popup/views/addressToken.js b/src/popup/views/addressToken.js index 1ee9a75..6754afe 100644 --- a/src/popup/views/addressToken.js +++ b/src/popup/views/addressToken.js @@ -161,7 +161,8 @@ function renderBalance() { const tokenId = state.selectedToken; let amount, price; if (tokenId === "ETH") { - amount = parseFloat(addr.balance || "0"); + // null when no refresh has read the balance. + amount = unknownableAmount(addr.balance); price = getPrice("ETH"); } else { const id = tokenId.toLowerCase(); @@ -372,7 +373,8 @@ function init(_ctx) { state.wallets[state.selectedWallet].addresses[ state.selectedAddress ]; - if (!addr.balance || parseFloat(addr.balance) === 0) { + // A null balance is one no refresh has read, so it is not refused. + if (parseFloat(addr.balance) === 0) { if (state.selectedToken === "ETH") { showFlash("Cannot send \u2014 zero balance."); return; diff --git a/src/popup/views/deleteAddress.js b/src/popup/views/deleteAddress.js index f39474f..2fba841 100644 --- a/src/popup/views/deleteAddress.js +++ b/src/popup/views/deleteAddress.js @@ -87,15 +87,22 @@ function recoveryPathText(wallet) { // when every holding here is one with no price, since "$0.00" directly under // "This address holds a balance." is a contradiction. A token holding below // 0.000001 does not count, as the lines below leave it out. +// +// While no refresh has read the ETH balance the address may be empty, so the +// sentence says "may hold". function balanceWarningHtml(addr) { if (!addressHoldsFunds(addr)) return " "; const line = formatAddressTotal(getAddressValue(addr)); const total = line ? `
This address holds a balance. Removing it does not ` + - `move or spend anything; the balance stays at the address.
` + + `${sentence} Removing it does not move or spend ` + + `anything; the balance stays at the address.
` + balanceLinesForAddress(addr, state.trackedTokens, false) + total ); diff --git a/src/popup/views/helpers.js b/src/popup/views/helpers.js index 9198ee0..61801fb 100644 --- a/src/popup/views/helpers.js +++ b/src/popup/views/helpers.js @@ -283,9 +283,10 @@ function showFlash(msg, duration = 2000) { flashTimer = setTimeout(clearFlash, duration); } -// A stored token balance as a number, or null when there is no number in it. -// balances.js writes null for a holding whose scale nothing knows, and this -// keeps that null from becoming a zero one dereference later. +// A stored balance as a number, or null when there is no number in it. +// balances.js writes null for a holding whose scale nothing knows, and an +// address's ETH balance is null until a refresh has read it. This keeps that +// null from becoming a zero one dereference later. function unknownableAmount(balance) { if (balance == null) return null; const n = parseFloat(balance); @@ -394,10 +395,10 @@ function formatFee(wei, symbol, chainId) { // holding renders through here. // // `amount` is null for a holding whose scale nothing knows -// (https://git.eeqj.de/sneak/AutistMask/issues/349). There is no quantity to -// print for it and no fiat value to derive from one, and printing 0.0000 for -// a real holding is the failure this whole rule exists to prevent, so the row -// says so instead. +// (https://git.eeqj.de/sneak/AutistMask/issues/349), and for a balance no +// refresh has read. There is no quantity to print for it and no fiat value to +// derive from one, and printing 0.0000 for a real holding is the failure this +// whole rule exists to prevent, so the row says so instead. function balanceLine(symbol, amount, price, tokenId) { const qty = amount === null ? "quantity unknown" : amount.toFixed(4); const usd = @@ -440,7 +441,7 @@ function markTrackedTokensNotFetched() { function balanceLinesForAddress(addr, trackedTokens, showZero) { let html = balanceLine( nativeCurrency(), - parseFloat(addr.balance || "0"), + unknownableAmount(addr.balance), getPrice("ETH"), "ETH", ); @@ -493,7 +494,10 @@ function balanceLinesForAddress(addr, trackedTokens, showZero) { // about holdings must ask this, not the rendered figure. function addressHoldsFunds(addr) { if (!addr) return false; - if (parseFloat(addr.balance || "0") > 0) return true; + // A null or absent ETH balance reads as unknown, so the address may hold + // funds. Warning about funds must err towards warning. + if (addr.balance == null) return true; + if (parseFloat(addr.balance) > 0) return true; for (const t of addr.tokenBalances || []) { if (isBelowOneMillionth(t.balance)) continue; // A null balance is a holding whose amount could not be stated — diff --git a/src/popup/views/home.js b/src/popup/views/home.js index 3758fbe..c6c880f 100644 --- a/src/popup/views/home.js +++ b/src/popup/views/home.js @@ -3,6 +3,7 @@ const { showView, showFlash, balanceLinesForAddress, + unknownableAmount, markTrackedTokensNotFetched, isoDate, timeAgo, @@ -76,10 +77,16 @@ function renderTotalValue() { if (subEl) subEl.innerHTML = " "; return; } - const ethBal = parseFloat(addr.balance || "0"); - const ethStr = ethBal.toFixed(4) + " " + nativeCurrency(); - const ethUsd = ethPrice ? " (" + formatUsd(ethBal * ethPrice) + ")" : ""; - el.textContent = ethStr + ethUsd; + const ethBal = unknownableAmount(addr.balance); + if (ethBal === null) { + el.textContent = "unknown (" + nativeCurrency() + ")"; + } else { + const ethStr = ethBal.toFixed(4) + " " + nativeCurrency(); + const ethUsd = ethPrice + ? " (" + formatUsd(ethBal * ethPrice) + ")" + : ""; + el.textContent = ethStr + ethUsd; + } if (subEl) { subEl.innerHTML = @@ -411,7 +418,7 @@ function render(ctx) { wallet.addresses.push({ address: newAddr, index: wallet.nextIndex, - balance: "0.0000", + balance: null, tokenBalances: [], }); wallet.nextIndex++; @@ -498,7 +505,8 @@ function init(ctx) { return; } const addr = currentAddress(); - if (!addr.balance || parseFloat(addr.balance) === 0) { + // A null balance is one no refresh has read, so it is not refused. + if (parseFloat(addr.balance) === 0) { showFlash("Cannot send \u2014 zero balance."); return; } diff --git a/src/popup/views/send.js b/src/popup/views/send.js index b8f3301..8895370 100644 --- a/src/popup/views/send.js +++ b/src/popup/views/send.js @@ -230,11 +230,14 @@ function updateSendBalance() { attachCopyHandlers($("send-from")); const token = state.selectedToken || $("send-token").value; if (token === "ETH") { + // A null balance is one no refresh has read. $("send-balance").textContent = - "Current balance: " + - truncateAmountNeverZero(addr.balance || "0") + - " " + - nativeCurrency(); + addr.balance == null + ? "Current balance: unknown (" + nativeCurrency() + ")" + : "Current balance: " + + truncateAmountNeverZero(addr.balance) + + " " + + nativeCurrency(); } else { const symbol = resolveSymbol( token, @@ -289,6 +292,12 @@ async function fillMaxAmount() { return; } + // A null balance is one no refresh has read. + if (addr.balance == null) { + showFlash("This token's balance is unknown."); + return; + } + // The fee is estimated for this recipient, as the confirmation screen // estimates it: sending to a contract can cost more gas. const to = $("send-to").value.trim(); @@ -306,7 +315,7 @@ async function fillMaxAmount() { provider.estimateGas({ from: addr.address, to, - value: parseEther(addr.balance || "0"), + value: parseEther(addr.balance), }), ]); feeWei = feeReserveWei(gasLimit, feeData); @@ -321,8 +330,10 @@ async function fillMaxAmount() { } // While the estimate was in flight the user left the screen (and perhaps // opened it again), typed an amount, or changed the address, the holding - // or the recipient: what they did wins. + // or the recipient: what they did wins. A network switch in that time + // leaves no balance to fill in from. if ( + addr.balance == null || state.currentView !== "send" || sendScreenOpenings !== opening || currentAddress()?.address !== addr.address || diff --git a/src/shared/chainSwitch.js b/src/shared/chainSwitch.js index 2b8a977..fe298f9 100644 --- a/src/shared/chainSwitch.js +++ b/src/shared/chainSwitch.js @@ -49,7 +49,7 @@ function clearPopupCaches() { // follow runs first, and this switch is made after it. // // The save writes only the fields that differ from what this page last saved. -// When this page last saved a balance of "0", clearing that balance is no +// When this page last saved a balance of null, clearing that balance is no // change. So the switch also resets these fields in the stored record: // // - every address's balance and token balances; @@ -100,7 +100,7 @@ async function onChainSwitch(newNetworkId) { stored.fraudContracts = []; for (const wallet of stored.wallets) { for (const addr of wallet.addresses) { - addr.balance = "0"; + addr.balance = null; addr.tokenBalances = []; } } diff --git a/src/shared/chainSwitchFields.js b/src/shared/chainSwitchFields.js index 7314a71..4c33998 100644 --- a/src/shared/chainSwitchFields.js +++ b/src/shared/chainSwitchFields.js @@ -68,10 +68,11 @@ function applyChainSwitchFields(s, newNetworkId) { // Clear per-address balances and token balances so stale data // from the previous chain is never displayed while the first - // refresh on the new chain is in flight. + // refresh on the new chain is in flight. A balance of null is one no + // refresh has read: it reads as unknown until one has, not as 0.0000. for (const wallet of s.wallets || []) { for (const addr of wallet.addresses || []) { - addr.balance = "0"; + addr.balance = null; addr.tokenBalances = []; } } @@ -108,7 +109,7 @@ function copyChainSwitchFields(s, switched) { for (const wallet of s.wallets) { for (const addr of wallet.addresses) { const got = stored.get(addr.address); - addr.balance = got ? got.balance : "0"; + addr.balance = got ? got.balance : null; addr.tokenBalances = got ? got.tokenBalances : []; } } diff --git a/src/shared/prices.js b/src/shared/prices.js index 240fb59..1c9d0d0 100644 --- a/src/shared/prices.js +++ b/src/shared/prices.js @@ -146,7 +146,8 @@ function formatUsd(amount) { // way an absent holders_count is not a count of zero. // // usd: the value of the holdings a price is known for, or null when -// nothing is knowable at all — testnet, or before the first fetch. +// no total can be stated — testnet, before the first fetch, or an +// ETH balance no refresh has read. // partial: the address also holds a token with no price, so usd is a floor // and not the total. // @@ -155,7 +156,8 @@ function getAddressValue(addr) { const { currentNetwork } = require("./state"); if (currentNetwork().isTestnet) return { usd: null, partial: false }; if (!prices.ETH) return { usd: null, partial: false }; - let usd = parseFloat(addr.balance || "0") * prices.ETH; + if (addr.balance == null) return { usd: null, partial: false }; + let usd = parseFloat(addr.balance) * prices.ETH; let partial = false; for (const token of addr.tokenBalances || []) { // A holding below 0.000001 is left out, as the balance lists leave it diff --git a/tests/addedTokenBalance.test.js b/tests/addedTokenBalance.test.js index 1fcf2e6..84f4f79 100644 --- a/tests/addedTokenBalance.test.js +++ b/tests/addedTokenBalance.test.js @@ -15,6 +15,10 @@ // added, and after a network switch, so it reads the same until a refresh has // read it (https://git.eeqj.de/sneak/AutistMask/issues/720). // +// An address's ETH balance is null until a refresh has read that address, so +// its ETH line reads the same, and Send states it as unknown +// (https://git.eeqj.de/sneak/AutistMask/issues/736). +// // Driven through the real popup over a stored profile on Ethereum Mainnet, // with "Show tracked tokens with zero balance" on, as it is by default. @@ -125,10 +129,10 @@ function bootOnDerivingWallet() { } // Holds the next balance refresh open. The returned function lets it finish -// with the address's ETH balance at 2 and `tokenBalances` as its token -// entries, having read the balance of every token tracked when it started. -// Given null, it finishes having read no token's balance, as when every -// balanceOf fails, and leaves the token entries as they were. +// with every address's ETH balance at 2 and `tokenBalances` as the first +// address's token entries, having read the balance of every token tracked when +// it started. Given null, it finishes having read no token's balance, as when +// every balanceOf fails, and leaves the token entries as they were. function holdNextRefresh(page) { const { refreshBalances } = require("../src/shared/balances"); let release; @@ -138,7 +142,9 @@ function holdNextRefresh(page) { const tokenBalances = await new Promise((resolve) => { release = resolve; }); - wallets[0].addresses[0].balance = "2.0"; + for (const wallet of wallets) { + for (const addr of wallet.addresses) addr.balance = "2.0"; + } if (tokenBalances === null) return []; wallets[0].addresses[0].tokenBalances = tokenBalances; return read; @@ -244,6 +250,22 @@ function newTokenRowsOnHome(page) { ); } +// Every ETH row in the balance list in element `id`, as markup, in the order +// the list holds them. +function ethRows(page, id) { + return page + .node(id) + .innerHTML.split("