From 617bd91688012224e8b07868f0c4d6e04420cbfc Mon Sep 17 00:00:00 2001 From: sneak Date: Tue, 3 Nov 2026 09:08:25 +0000 Subject: [PATCH] fix: the ETH line reads unknown at an address no refresh has read (closes #736) An address's ETH balance is now null until a refresh has read it. That covers a new address, a new wallet, addresses a wallet scan finds, and every address after a network switch. Its line reads `quantity unknown`, Home's balance `unknown (ETH)`, and Send `Current balance: unknown (ETH)`. Max says the balance is unknown. Send is no longer refused as zero. The address has no total, and DeleteAddress warns that it may hold a balance. A refresh also deletes `balanceNotFetched` only when it read every address in the stored record. Model: opus-5-5 --- README.md | 25 +- src/popup/index.js | 17 +- src/popup/views/addWallet.js | 10 +- src/popup/views/addressDetail.js | 3 +- src/popup/views/addressToken.js | 6 +- src/popup/views/deleteAddress.js | 11 +- src/popup/views/helpers.js | 22 +- src/popup/views/home.js | 20 +- src/popup/views/send.js | 23 +- src/shared/chainSwitch.js | 4 +- src/shared/chainSwitchFields.js | 7 +- src/shared/prices.js | 6 +- tests/addedTokenBalance.test.js | 306 +++++++++++++++++++++- tests/addressValue.test.js | 8 + tests/backgroundStateIsolation.test.js | 2 +- tests/balanceRefreshNetworkSwitch.test.js | 12 +- tests/chainSwitchGate.test.js | 2 +- tests/coldWorkerChainSwitch.test.js | 2 +- tests/deleteAddress.test.js | 28 +- tests/popupFollowsNetworkSwitch.test.js | 2 +- tests/sendMax.test.js | 21 ++ 21 files changed, 470 insertions(+), 67 deletions(-) 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 ? `
${escapeHtml(line)}
` : ""; + const sentence = + addr.balance == null + ? "This address may hold a balance." + : "This address holds a balance."; return ( - `

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("
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..42b157a 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,14 +107,23 @@ 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); }); - test("a missing address or missing fields do not", () => { + test("a missing address does not", () => { expect(addressHoldsFunds(undefined)).toBe(false); - expect(addressHoldsFunds({ address: "0x1" })).toBe(false); + }); + + // It reads as unknown, as a null balance does. + test("an address with no ETH balance field counts", () => { + expect(addressHoldsFunds({ address: "0x1" })).toBe(true); }); }); @@ -140,6 +150,20 @@ describe("the balance warning on the removal confirmation", () => { expect(html).toContain("0.0000"); }); + 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 may hold a balance."); + expect(html).not.toContain("This address holds a balance."); + expect(html).toContain("quantity unknown"); + expect(html).not.toContain("0.0000"); + }); + + test("an address with no ETH balance field reads the same", () => { + expect(balanceWarningHtml({ address: "0x1" })).toContain( + "This address may hold a balance.", + ); + }); + // 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. 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. -- 2.54.0