diff --git a/README.md b/README.md index 868120f..554c3b5 100644 --- a/README.md +++ b/README.md @@ -1025,6 +1025,7 @@ 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. "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 +1213,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 +1267,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/helpers.js b/src/popup/views/helpers.js index 9198ee0..be4842e 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 ETH balance is one no refresh has read, 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..f1b482a 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. +// nothing is knowable at all — 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("