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("
row.includes('data-token="ETH"')); +} + +// A stored profile whose one address no refresh has read. +function profileNotRead(extra) { + const profile = unversionedValidProfile(extra); + profile.wallets[0].addresses[0].balance = null; + return profile; +} + // The "+" button Home draws for the first wallet. As in firstRemoveButton(), // the list hands Home a stand-in for that button each time it is drawn, and // `press()` presses the last one drawn. Call it before Home next draws its list. @@ -760,3 +782,277 @@ test("after a switch saved by the background, a tracked token reads quantity unk expect(row).toContain("quantity unknown"); expect(row).not.toContain("0.0000"); }); + +test("a refresh deletes no balanceNotFetched while the stored record holds an address it did not read", async () => { + const page = await bootOnAddress({ + trackedTokens: [{ ...NEW_TRACKED, balanceNotFetched: true }], + }); + // Another popup page adds an address. This page is not told of the write. + const record = page.storage.read("autistmask"); + record.wallets[0].addresses.push({ + address: "0x" + "dd".repeat(20), + balance: null, + tokenBalances: [], + }); + page.storage.write("autistmask", record); + + await refreshOnce(page, []); + + expect( + page.storage.read("autistmask").trackedTokens[0].balanceNotFetched, + ).toBe(true); + expect(newTokenRow(page)).toContain("quantity unknown"); +}); + +test("an address just added reads quantity unknown for ETH until a refresh has read it, then the fetched balance", async () => { + const page = await bootOnDerivingWallet(); + const plus = plusButton(page); + await refreshOnce(page, []); + const finish = holdNextRefresh(page); + + await plus.press(); + await page.settle(); + + const [held, added] = ethRows(page, "wallet-list"); + expect(held).toContain("2.0000"); + expect(added).toContain("quantity unknown"); + expect(added).not.toContain("0.0000"); + expect( + page.storage.read("autistmask").wallets[0].addresses[1].balance, + ).toBeNull(); + + await finish([]); + + expect(ethRows(page, "wallet-list")[1]).toContain("2.0000"); +}); + +test('an address "+" adds during a refresh reads quantity unknown for ETH when that refresh returns', async () => { + const page = await bootOnDerivingWallet(); + const plus = plusButton(page); + await refreshOnce(page, []); + const finishRunning = holdNextRefresh(page); + const ticking = page.tick(); + await page.settle(); + + await plus.press(); + await page.settle(); + const finishNext = holdNextRefresh(page); + await finishRunning([]); + await ticking; + + const added = ethRows(page, "wallet-list")[1]; + expect(added).toContain("quantity unknown"); + expect(added).not.toContain("0.0000"); + + await finishNext([]); + + expect(ethRows(page, "wallet-list")[1]).toContain("2.0000"); +}); + +test.each([FROM_PHRASE, FROM_PRIVATE_KEY, FROM_XPRV])( + "a wallet just added from $tab reads quantity unknown for ETH until a refresh has read it", + async (source) => { + const page = await bootPopup(unversionedValidProfile()); + const finish = holdNextRefresh(page); + + await addWallet(page, source); + + const added = ethRows(page, "wallet-list")[1]; + expect(added).toContain("quantity unknown"); + expect(added).not.toContain("0.0000"); + + await finish([]); + + expect(ethRows(page, "wallet-list")[1]).toContain("2.0000"); + }, +); + +test.each([FROM_PHRASE, FROM_XPRV])( + "addresses the scan finds for a wallet from $tab read quantity unknown for ETH until a refresh has read them", + async (source) => { + const page = await bootPopup(unversionedValidProfile()); + const { scanForAddresses } = require("../src/shared/balances"); + let finish; + scanForAddresses.mockImplementationOnce(async () => { + await refreshOnce(page, []); + finish = holdNextRefresh(page); + return { + addresses: [ + { address: "0x" + "dd".repeat(20), index: 0 }, + { address: "0x" + "ee".repeat(20), index: 1 }, + ], + nextIndex: 2, + }; + }); + + await addWallet(page, source); + + const [, first, second] = ethRows(page, "wallet-list"); + for (const found of [first, second]) { + expect(found).toContain("quantity unknown"); + expect(found).not.toContain("0.0000"); + } + + await finish([]); + + expect(ethRows(page, "wallet-list")[2]).toContain("2.0000"); + }, +); + +test("after a network switch in Settings, the ETH line reads quantity unknown until a refresh has read it, then the fetched balance", async () => { + const page = await bootOnAddress(); + + await page.click("btn-settings"); + await chooseNetwork(page, "sepolia"); + await page.click("btn-settings-back"); + + expect(page.visibleViews()).toEqual(["address"]); + const [row] = ethRows(page, "address-balances"); + expect(row).toContain("quantity unknown"); + expect(row).not.toContain("0.0000"); + expect( + page.storage.read("autistmask").wallets[0].addresses[0].balance, + ).toBeNull(); + + await refreshOnce(page, []); + + expect(ethRows(page, "address-balances")[0]).toContain("2.0000"); +}); + +test("after a switch saved by the background, the ETH line reads quantity unknown until a refresh has read it, then the fetched balance", async () => { + const page = await bootOnAddress(); + const { + applyChainSwitchFields, + } = require("../src/shared/chainSwitchFields"); + const record = page.storage.read("autistmask"); + applyChainSwitchFields(record, "sepolia"); + page.storage.write("autistmask", record); + + // The popup takes the switch, and this refresh reads no ETH balance. + await page.tick(); + + const [row] = ethRows(page, "address-balances"); + expect(row).toContain("quantity unknown"); + expect(row).not.toContain("0.0000"); + + await refreshOnce(page, []); + + expect(ethRows(page, "address-balances")[0]).toContain("2.0000"); +}); + +test("a switch taken from the stored record leaves an address the record does not have with no ETH balance", () => { + const { + copyChainSwitchFields, + } = require("../src/shared/chainSwitchFields"); + const taker = { + wallets: [ + { + addresses: [ + { address: "0x1", balance: "1.5", tokenBalances: [] }, + ], + }, + ], + }; + + copyChainSwitchFields(taker, { wallets: [] }); + + expect(taker.wallets[0].addresses[0].balance).toBeNull(); +}); + +test("closed and opened again before any refresh has read it, the ETH line still reads quantity unknown", async () => { + const closed = await bootOnAddress(); + await closed.click("btn-settings"); + await chooseNetwork(closed, "sepolia"); + await closed.click("btn-settings-back"); + cleanupPopup(); + + const page = await bootPopup(undefined, { storage: closed.storage }); + + expect(page.visibleViews()).toEqual(["address"]); + const [row] = ethRows(page, "address-balances"); + expect(row).toContain("quantity unknown"); + expect(row).not.toContain("0.0000"); +}); + +test("Home's ETH balance reads unknown until a refresh has read it, then the fetched balance", async () => { + const page = await bootPopup(profileNotRead()); + + expect(page.visibleViews()).toEqual(["main"]); + expect(page.text("total-value")).toBe("unknown (ETH)"); + + await refreshOnce(page, []); + + expect(page.text("total-value")).toBe("2.0000 ETH"); +}); + +test("AddressToken for ETH reads quantity unknown until a refresh has read it, then the fetched balance", async () => { + const page = await bootPopup( + profileNotRead({ + currentView: "address-token", + viewStack: ["main", "address"], + selectedWallet: 0, + selectedAddress: 0, + selectedToken: "ETH", + }), + ); + + expect(page.visibleViews()).toEqual(["address-token"]); + const before = page.node("address-token-balance").innerHTML; + expect(before).toContain("quantity unknown"); + expect(before).not.toContain("0.0000"); + + await refreshOnce(page, []); + + expect(page.node("address-token-balance").innerHTML).toContain("2.0000"); +}); + +test.each([ + ["main", {}, "btn-main-send"], + [ + "address", + { currentView: "address", selectedWallet: 0, selectedAddress: 0 }, + "btn-send", + ], + [ + "address-token", + { + currentView: "address-token", + viewStack: ["main", "address"], + selectedWallet: 0, + selectedAddress: 0, + selectedToken: "ETH", + }, + "btn-address-token-send", + ], +])( + "Send from %s reads an unread ETH balance as unknown, Max says so, and only a balance read as zero is refused", + async (view, extra, sendButton) => { + const page = await bootPopup(profileNotRead(extra)); + expect(page.visibleViews()).toEqual([view]); + // The dropdown's first option, which a browser selects by itself. + page.node("send-token").value = "ETH"; + + await page.click(sendButton); + + expect(page.visibleViews()).toEqual(["send"]); + expect(page.text("send-balance")).toBe( + "Current balance: unknown (ETH)", + ); + + await page.click("btn-send-max"); + + expect(page.text("flash-msg")).toBe("This token's balance is unknown."); + + await page.click("btn-send-back"); + const { refreshBalances } = require("../src/shared/balances"); + refreshBalances.mockImplementationOnce(async (wallets) => { + wallets[0].addresses[0].balance = "0.0"; + return []; + }); + await page.tick(); + await page.click(sendButton); + + expect(page.visibleViews()).toEqual([view]); + expect(page.text("flash-msg")).toBe("Cannot send — zero balance."); + }, +); diff --git a/tests/addressValue.test.js b/tests/addressValue.test.js index ea5fe0a..3e8833b 100644 --- a/tests/addressValue.test.js +++ b/tests/addressValue.test.js @@ -125,6 +125,14 @@ describe("the value of an address, and whether it is the whole value", () => { expect(getAddressValue(addr)).toEqual({ usd: 0, partial: false }); }); + // No total can include an ETH balance no refresh has read, and "unpriced + // tokens only" would be untrue of an address with nothing known at all. + test("an ETH balance no refresh has read means no value", () => { + const addr = { address: "0x1", balance: null, tokenBalances: [] }; + expect(getAddressValue(addr)).toEqual({ usd: null, partial: false }); + expect(formatAddressTotal(getAddressValue(addr))).toBe(""); + }); + // Before the first price fetch, and on testnet, nothing is knowable: that // is a third state, and it stays distinct from both of the others. test("no prices at all means no value, not an incomplete one", () => { diff --git a/tests/backgroundStateIsolation.test.js b/tests/backgroundStateIsolation.test.js index 3ea75c9..4802e7c 100644 --- a/tests/backgroundStateIsolation.test.js +++ b/tests/backgroundStateIsolation.test.js @@ -445,7 +445,7 @@ describe("a balance refresh under a network switch", () => { const after = bg.persisted(); expect(after.networkId).toBe("mainnet"); - expect(after.wallets[0].addresses[0].balance).toBe("0"); + expect(after.wallets[0].addresses[0].balance).toBeNull(); expect(after.lastBalanceRefresh).toBe(0); await bg.fireBalanceAlarm(); diff --git a/tests/balanceRefreshNetworkSwitch.test.js b/tests/balanceRefreshNetworkSwitch.test.js index 920a6f8..b548e84 100644 --- a/tests/balanceRefreshNetworkSwitch.test.js +++ b/tests/balanceRefreshNetworkSwitch.test.js @@ -70,7 +70,7 @@ async function chooseNetwork(page, networkId) { function expectNothingStored(page) { const stored = page.storage.read("autistmask"); expect(stored.networkId).toBe("sepolia"); - expect(stored.wallets[0].addresses[0].balance).toBe("0"); + expect(stored.wallets[0].addresses[0].balance).toBeNull(); expect(stored.wallets[0].addresses[0].tokenBalances).toEqual([]); expect(stored.lastBalanceRefresh).toBe(0); } @@ -86,13 +86,13 @@ test("a refresh that returns after a switch in Settings writes nothing, and the await chooseNetwork(page, "sepolia"); await refresh.finish(); - expect(state.wallets[0].addresses[0].balance).toBe("0"); + expect(state.wallets[0].addresses[0].balance).toBeNull(); expect(state.wallets[0].addresses[0].tokenBalances).toEqual([]); expectNothingStored(page); await page.click("btn-settings-back"); expect(page.visibleViews()).toEqual(["main"]); - expect(page.text("total-value")).toBe("0.0000 SepoliaETH"); + expect(page.text("total-value")).toBe("unknown (SepoliaETH)"); await page.tick(); @@ -138,7 +138,7 @@ test("a refresh whose save is reading the stored record when the user switches i await page.settle(); expect(state.networkId).toBe("sepolia"); - expect(state.wallets[0].addresses[0].balance).toBe("0"); + expect(state.wallets[0].addresses[0].balance).toBeNull(); expect(state.wallets[0].addresses[0].tokenBalances).toEqual([]); expectNothingStored(page); }); @@ -166,11 +166,11 @@ test("a refresh that returns after the background switched the network writes no }); // The background's refresh saves Ethereum Mainnet's balances while the switch -// in Settings waits for the lock. This page last saved a balance of "0" and no +// in Settings waits for the lock. This page last saved a balance of null and no // token balances, the values that the switch sets. test("a background refresh saved while a switch in Settings waits for the lock leaves none of its balances stored", async () => { const profile = unversionedValidProfile(); - profile.wallets[0].addresses[0].balance = "0"; + profile.wallets[0].addresses[0].balance = null; const page = await bootPopup(profile); expect(page.pageErrors).toEqual([]); const background = require("../src/background/state"); diff --git a/tests/chainSwitchGate.test.js b/tests/chainSwitchGate.test.js index da0a097..da240c0 100644 --- a/tests/chainSwitchGate.test.js +++ b/tests/chainSwitchGate.test.js @@ -328,7 +328,7 @@ describe("only the user switches the network", () => { const after = bg.walletState(); expect(after.networkId).toBe("sepolia"); expect(after.rpcUrl).toBe(SEPOLIA.defaultRpcUrl); - expect(after.wallets[0].addresses[0].balance).toBe("0"); + expect(after.wallets[0].addresses[0].balance).toBeNull(); expect(bg.chainChangedEvents()).toEqual([ { type: "AUTISTMASK_EVENT", diff --git a/tests/coldWorkerChainSwitch.test.js b/tests/coldWorkerChainSwitch.test.js index a89a2f7..60b1d53 100644 --- a/tests/coldWorkerChainSwitch.test.js +++ b/tests/coldWorkerChainSwitch.test.js @@ -39,7 +39,7 @@ function walletFixture() { { name: "Wallet 1", type: "hd", - addresses: [{ address: ADDRESS, balance: "0", tokenBalances: [] }], + addresses: [{ address: ADDRESS, balance: null, tokenBalances: [] }], }, ]; } diff --git a/tests/deleteAddress.test.js b/tests/deleteAddress.test.js index 5d04911..7cfce7b 100644 --- a/tests/deleteAddress.test.js +++ b/tests/deleteAddress.test.js @@ -33,6 +33,7 @@ const USDC = "0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48"; const EMPTY = { address: "0x1", balance: "0.0000", tokenBalances: [] }; const ETH_ONLY = { address: "0x1", balance: "1.5", tokenBalances: [] }; const DUST = { address: "0x1", balance: "0.00001", tokenBalances: [] }; +const NOT_READ = { address: "0x1", balance: null, tokenBalances: [] }; const TOKEN_ONLY = { address: "0x1", balance: "0.0000", @@ -106,6 +107,11 @@ describe("whether an address holds anything", () => { expect(addressHoldsFunds(DUST)).toBe(true); }); + // No refresh has read it, so the address may hold funds. + test("an ETH balance no refresh has read counts", () => { + expect(addressHoldsFunds(NOT_READ)).toBe(true); + }); + test("an address holding nothing does not", () => { expect(addressHoldsFunds(EMPTY)).toBe(false); expect(addressHoldsFunds(ZERO_TOKEN)).toBe(false); @@ -143,6 +149,13 @@ describe("the balance warning on the removal confirmation", () => { // The sentence must not assert an amount, because any amount it could // assert has been rounded: "This address holds 0.0000 ETH." is what the // rounded form produces for an address that holds real money. + test("an ETH balance no refresh has read is warned about, and reads as unknown", () => { + const html = balanceWarningHtml(NOT_READ); + expect(html).toContain("This address holds a balance"); + expect(html).toContain("quantity unknown"); + expect(html).not.toContain("0.0000"); + }); + test("the warning sentence asserts no rounded amount", () => { for (const addr of [DUST, ETH_ONLY, TOKEN_ONLY]) { expect(balanceWarningHtml(addr)).not.toMatch( diff --git a/tests/popupFollowsNetworkSwitch.test.js b/tests/popupFollowsNetworkSwitch.test.js index bf1b293..2f186d9 100644 --- a/tests/popupFollowsNetworkSwitch.test.js +++ b/tests/popupFollowsNetworkSwitch.test.js @@ -134,7 +134,7 @@ test.each([ await backgroundSwitch(page, "sepolia"); expect(page.visibleViews()).toEqual(["main"]); - expect(page.text("total-value")).toBe("0.0000 SepoliaETH"); + expect(page.text("total-value")).toBe("unknown (SepoliaETH)"); }); // A request on Ethereum Mainnet fails, so Home's list holds the failure message. diff --git a/tests/sendMax.test.js b/tests/sendMax.test.js index cd64adb..c7535ff 100644 --- a/tests/sendMax.test.js +++ b/tests/sendMax.test.js @@ -432,6 +432,27 @@ describe("Max on an ETH send", () => { }, ); + test("says so when no refresh has read the ETH balance", async () => { + await refreshWith(BALANCE_WEI); + state.wallets[0].addresses[0].balance = null; + openSend(); + await pressMax(); + expect(el("send-amount").value).toBe(""); + expect(text("flash-msg")).toBe("This token's balance is unknown."); + }); + + test("fills nothing in and says nothing when a network switch cleared the balance while the fee was estimated", async () => { + await refreshWith(BALANCE_WEI); + openSend(); + const release = holdFeeEstimate(); + const pressed = pressMax(); + state.wallets[0].addresses[0].balance = null; + release(); + await pressed; + expect(el("send-amount").value).toBe(""); + expect(text("flash-msg")).toBe(""); + }); + test("fills nothing in and says nothing once Send was left while the fee was estimated", async () => { // 0.0001 ETH, which does not cover the fee: a result that landed // would say so on whichever screen is shown.