diff --git a/README.md b/README.md index f53e5b3..5629847 100644 --- a/README.md +++ b/README.md @@ -1481,7 +1481,9 @@ send-screen token selector, the address total and the remove-address warning (`isBelowOneMillionth()` in `src/shared/amountDisplay.js`). The Send and confirmation screens show it when its token is the one being sent. Tracked tokens with a zero balance are listed as well while "Show tracked tokens with -zero balance" is on. +zero balance" is on. While that setting is on, a token just added reads +`quantity unknown` until a refresh has read its balance at every address (see +AddToken). #### Stored state and its version @@ -1665,6 +1667,13 @@ network switch (below). That popup then redraws its top screen as a reopened popup would. A screen that a reopened popup cannot render, such as Send, is replaced by Home. +The popup refreshes balances every ten seconds while it is open +(`doRefreshAndRender()` in `src/popup/index.js`). Each refresh that returns +draws the wallet list again. When AddressDetail or AddressToken is up, the +refresh also draws that screen's USD total and balance lines again, but not its +transaction list. A refresh draws no other screen again, DeleteAddress and its +balance lines included. + A network switch leaves every screen on the page showing the previous network. The user makes it in Settings, or approves a site's request and the background saves it. Any other popup open meanwhile takes the switch from the stored @@ -2221,7 +2230,14 @@ view would leave a wallet one click from deletion. - "Add" button - **Transitions**: - "Add" (valid contract) → tracks the token, pops the stack, and re-renders - **AddressDetail** + **AddressDetail**. The token's line reads `quantity unknown`, never + `0.0000`, until a balance refresh on the token's network has read its + balance at every address. Where Blockscout already listed a holding of the + token, the line shows that holding. Until that refresh, the tracked + token's record carries `balanceNotFetched`, so the line still reads + `quantity unknown` after the popup closes and opens again. A refresh that + returns draws AddressDetail's balances again (see Navigation), so the + fetched balance shows without the screen being reopened - "Add" with a token already tracked, a scam-listed address, or a failed contract lookup → flash message, no screen change - "Add", then a network switch in Settings before the contract lookup @@ -2522,7 +2538,9 @@ view would leave a wallet one click from deletion. add path - **Transitions**: - Any of the add paths shown, on success → adds the token and shows an - "Added SYMBOL" flash message (no screen change) + "Added SYMBOL" flash message (no screen change). Until a refresh has read + the token's balance, its line reads `quantity unknown`, as after "Add" on + AddToken - A duplicate, a scam-listed address, or a failed contract lookup → flash message, no screen change - "Add" by contract address, then a network switch in Settings before the diff --git a/src/popup/index.js b/src/popup/index.js index f5888af..e3309a5 100644 --- a/src/popup/index.js +++ b/src/popup/index.js @@ -88,6 +88,14 @@ let refreshTimer = null; // removed meanwhile is left as it is. Blockscout may have answered before the // user turned "Use Blockscout" off. So the save drops every token the user has // not tracked if the stored setting is off by then. +// +// The save also deletes `balanceNotFetched` from each tracked token whose +// balance this refresh read at every address. trackToken() in +// src/popup/views/helpers.js sets that field. +// +// 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 +// drawn again, DeleteAddress and its balance lines included. async function doRefreshAndRender() { if (refreshInFlight) return; refreshInFlight = true; @@ -95,7 +103,7 @@ async function doRefreshAndRender() { await followStoredSettings(); const networkId = state.networkId; const wallets = structuredClone(state.wallets); - await Promise.all([ + const [, readTokens] = await Promise.all([ refreshPrices(), refreshBalances( wallets, @@ -127,10 +135,17 @@ async function doRefreshAndRender() { if (!stored.useBlockscout) { dropUntrackedTokens(state.wallets, stored.trackedTokens); } + for (const token of state.trackedTokens) { + if (readTokens.includes(token.address.toLowerCase())) { + delete token.balanceNotFetched; + } + } state.lastBalanceRefresh = Date.now(); return true; }); renderWalletList(); + if (state.currentView === "address") addressDetail.renderBalances(); + if (state.currentView === "address-token") addressToken.renderBalance(); } catch (e) { // Every call site fires this and walks away — the boot below, the ten // second interval, and eight views through ctx — so it must never diff --git a/src/popup/views/addToken.js b/src/popup/views/addToken.js index 76c96d0..19dfb0f 100644 --- a/src/popup/views/addToken.js +++ b/src/popup/views/addToken.js @@ -1,4 +1,11 @@ -const { $, showView, showFlash, escapeHtml, goBack } = require("./helpers"); +const { + $, + showView, + showFlash, + escapeHtml, + goBack, + trackToken, +} = require("./helpers"); const { getTopTokens } = require("../../shared/tokenList"); const { state, saveState, currentNetwork } = require("../../shared/state"); const { lookupTokenInfo } = require("../../shared/balances"); @@ -72,7 +79,7 @@ function init(ctx) { return; } log.infof("Adding token", info.symbol, contractAddr); - state.trackedTokens.push({ + trackToken({ address: contractAddr, symbol: info.symbol, decimals: info.decimals, diff --git a/src/popup/views/addressDetail.js b/src/popup/views/addressDetail.js index f633893..801bda6 100644 --- a/src/popup/views/addressDetail.js +++ b/src/popup/views/addressDetail.js @@ -66,11 +66,27 @@ function show() { }); $("address-line").dataset.full = addr.address; attachCopyHandlers($("address-line")); - const usdTotal = formatAddressTotal(getAddressValue(addr)); - $("address-usd-total").innerHTML = escapeHtml(usdTotal) || " "; const ensEl = $("address-ens"); // ENS is now shown inside renderAddressHtml, hide the separate element ensEl.classList.add("hidden"); + renderBalances(); + // Only Blockscout reports the tokens the user has not tracked. + $("address-tokens-note").classList.toggle("hidden", !!state.useBlockscout); + renderSendTokenSelect(addr); + $("tx-list").innerHTML = + '
Loading...
'; + showView("address"); + loadTransactions(addr.address); +} + +// The USD total and the balance list. Drawn again by each balance refresh +// that returns while this screen is up (doRefreshAndRender() in +// src/popup/index.js), which leaves the rest of the screen as it is. +function renderBalances() { + const addr = + state.wallets[state.selectedWallet].addresses[state.selectedAddress]; + const usdTotal = formatAddressTotal(getAddressValue(addr)); + $("address-usd-total").innerHTML = escapeHtml(usdTotal) || " "; $("address-balances").innerHTML = balanceLinesForAddress( addr, state.trackedTokens, @@ -84,13 +100,6 @@ function show() { ctx.showAddressToken(); }); }); - // Only Blockscout reports the tokens the user has not tracked. - $("address-tokens-note").classList.toggle("hidden", !!state.useBlockscout); - renderSendTokenSelect(addr); - $("tx-list").innerHTML = - '
Loading...
'; - showView("address"); - loadTransactions(addr.address); } let loadedTxs = []; @@ -287,4 +296,4 @@ function init(_ctx) { exportPrivkey.init(); } -module.exports = { init, show }; +module.exports = { init, show, renderBalances }; diff --git a/src/popup/views/addressToken.js b/src/popup/views/addressToken.js index e873068..48c0952 100644 --- a/src/popup/views/addressToken.js +++ b/src/popup/views/addressToken.js @@ -49,37 +49,18 @@ function show() { const addr = wallet.addresses[state.selectedAddress]; const ai = state.selectedAddress; const tokenId = state.selectedToken; - - // Determine token symbol and balance - let symbol, amount, price; const knownToken = TOKEN_BY_ADDRESS.get(tokenId.toLowerCase()); - if (tokenId === "ETH") { - symbol = nativeCurrency(); - amount = parseFloat(addr.balance || "0"); - price = getPrice("ETH"); - } else { - const tb = (addr.tokenBalances || []).find( - (t) => t.address.toLowerCase() === tokenId.toLowerCase(), - ); - symbol = resolveSymbol( - tokenId, - addr.tokenBalances, - state.trackedTokens, - ); - // null when the scale is unknown: no quantity to show, and none to - // price. balanceLine() states that rather than printing 0.0000. - amount = tb ? unknownableAmount(tb.balance) : 0; - price = getPrice(symbol); - } - - currentSymbol = symbol; + currentSymbol = + tokenId === "ETH" + ? nativeCurrency() + : resolveSymbol(tokenId, addr.tokenBalances, state.trackedTokens); $("address-token-title").textContent = wallet.name + " \u2014 Address " + (ai + 1) + " \u2014 " + - displaySymbol(symbol); + displaySymbol(currentSymbol); // Blockie const blockieEl = $("address-token-jazzicon"); @@ -101,13 +82,7 @@ function show() { $("address-token-line").dataset.full = addr.address; attachCopyHandlers($("address-token-line")); - // USD total for this token only - const usdVal = price && amount !== null ? amount * price : null; - const usdStr = formatUsd(usdVal); - $("address-token-usd-total").innerHTML = escapeHtml(usdStr) || " "; - - // Single token balance line (no tokenId — not clickable here) - $("address-token-balance").innerHTML = balanceLine(symbol, amount, price); + renderBalance(); // Token contract details (ERC-20 only) const contractInfo = $("address-token-contract-info"); @@ -169,6 +144,47 @@ function show() { loadTransactions(addr.address, tokenId); } +// The USD total and the balance line of the selected token. Drawn again by +// each balance refresh that returns while this screen is up +// (doRefreshAndRender() in src/popup/index.js), which leaves the rest of the +// screen as it is. +function renderBalance() { + const addr = + state.wallets[state.selectedWallet].addresses[state.selectedAddress]; + const tokenId = state.selectedToken; + let amount, price; + if (tokenId === "ETH") { + amount = parseFloat(addr.balance || "0"); + price = getPrice("ETH"); + } else { + const id = tokenId.toLowerCase(); + const tb = (addr.tokenBalances || []).find( + (t) => t.address.toLowerCase() === id, + ); + const tracked = state.trackedTokens.find( + (t) => t.address.toLowerCase() === id, + ); + // null when the scale is unknown, or when no refresh has read the + // balance of a token just added: no quantity to show, and none to + // price. balanceLine() states that rather than printing 0.0000. + if (tb) amount = unknownableAmount(tb.balance); + else amount = tracked && tracked.balanceNotFetched ? null : 0; + price = getPrice(currentSymbol); + } + + // USD total for this token only + const usdVal = price && amount !== null ? amount * price : null; + const usdStr = formatUsd(usdVal); + $("address-token-usd-total").innerHTML = escapeHtml(usdStr) || " "; + + // Single token balance line (no tokenId — not clickable here) + $("address-token-balance").innerHTML = balanceLine( + currentSymbol, + amount, + price, + ); +} + async function loadTransactions(address, tokenId) { try { const rawTxs = await fetchRecentTransactions( @@ -348,4 +364,4 @@ function init(_ctx) { }); } -module.exports = { init, show }; +module.exports = { init, show, renderBalance }; diff --git a/src/popup/views/helpers.js b/src/popup/views/helpers.js index 7152a48..ae272f6 100644 --- a/src/popup/views/helpers.js +++ b/src/popup/views/helpers.js @@ -417,6 +417,15 @@ function balanceLine(symbol, amount, price, tokenId) { ); } +// Tracks `token` on the active network, as every add path on AddToken and +// SettingsAddToken does. The tracked record carries `balanceNotFetched: true` +// until a balance refresh has read the token's balance at every address +// (doRefreshAndRender() in src/popup/index.js). Until then, an address with no +// balance row for the token shows `quantity unknown` for it, not 0.0000. +function trackToken(token) { + state.trackedTokens.push({ ...token, balanceNotFetched: true }); +} + function balanceLinesForAddress(addr, trackedTokens, showZero) { let html = balanceLine( nativeCurrency(), @@ -449,7 +458,7 @@ function balanceLinesForAddress(addr, trackedTokens, showZero) { if (seen.has(t.address.toLowerCase())) continue; html += balanceLine( t.symbol, - 0, + t.balanceNotFetched ? null : 0, getPrice(t.symbol), t.address.toLowerCase(), ); @@ -829,6 +838,7 @@ module.exports = { flashCopyFeedback, balanceLine, balanceLinesForAddress, + trackToken, addressHoldsFunds, keyMaterialName, unknownableAmount, diff --git a/src/popup/views/settingsAddToken.js b/src/popup/views/settingsAddToken.js index a420fe0..df57b94 100644 --- a/src/popup/views/settingsAddToken.js +++ b/src/popup/views/settingsAddToken.js @@ -1,4 +1,11 @@ -const { $, showView, showFlash, escapeHtml, goBack } = require("./helpers"); +const { + $, + showView, + showFlash, + escapeHtml, + goBack, + trackToken, +} = require("./helpers"); const { getTopTokens } = require("../../shared/tokenList"); const { state, saveState, currentNetwork } = require("../../shared/state"); const { lookupTokenInfo } = require("../../shared/balances"); @@ -43,7 +50,7 @@ function renderTop10() { decimals: parseInt(btn.dataset.decimals, 10), name: btn.dataset.name || btn.dataset.symbol, }; - state.trackedTokens.push(token); + trackToken(token); await saveState(); showFlash("Added " + token.symbol); renderTop10(); @@ -110,7 +117,7 @@ function init(_ctx) { decimals: parseInt(opt.dataset.decimals, 10), name: opt.dataset.name || opt.dataset.symbol, }; - state.trackedTokens.push(token); + trackToken(token); await saveState(); showFlash("Added " + token.symbol); renderTop10(); @@ -156,7 +163,7 @@ function init(_ctx) { return; } log.infof("Adding token", info.symbol, addr); - state.trackedTokens.push({ + trackToken({ address: addr, symbol: info.symbol, decimals: info.decimals, diff --git a/src/shared/balances.js b/src/shared/balances.js index fe4e4bc..33f73ae 100644 --- a/src/shared/balances.js +++ b/src/shared/balances.js @@ -247,7 +247,11 @@ async function fetchTokenBalances( // it has none. The failure is logged. A balance of zero gets no entry, as a // zero Blockscout reports gets none: the balance lists take a tracked token's // zero from the tracked list. A token whose symbol the spoof rule refuses gets -// no entry either, as in fetchTokenBalances(). +// no entry either, as in fetchTokenBalances(), and its balance is not read. +// +// Resolves with `rows`, the entries, and `unread`, the tracked tokens whose +// balance was not read, as lowercased contract addresses. A token is unread +// when the spoof rule refuses it or its balanceOf fails. // // `signal` is as for fetchTokenBalances(). async function fetchTrackedTokenBalances( @@ -288,12 +292,16 @@ async function fetchTrackedTokenBalances( const reported = explorerRows ?? previous; const rows = []; + const unread = trackedTokens + .filter((token) => !listed.includes(token)) + .map((token) => token.address.toLowerCase()); listed.forEach((token, i) => { const lower = token.address.toLowerCase(); const entryIn = (list) => list.find((row) => String(row.address).toLowerCase() === lower); const raw = balances[i]; if (raw === null) { + unread.push(lower); const kept = entryIn(previous) || entryIn(explorerRows || []); if (kept) rows.push(kept); return; @@ -314,12 +322,15 @@ async function fetchTrackedTokenBalances( holders: entry ? entry.holders : null, }); }); - return rows; + return { rows, unread }; } // Fetch ETH balances, ENS names, and ERC-20 token balances for all addresses. // `trackedTokens` are the tokens tracked on the active network. `signal` is as // for fetchTokenBalances(). +// +// Resolves with the lowercased contract addresses of the tracked tokens whose +// balance it read at every address. async function refreshBalances( wallets, rpcUrl, @@ -332,6 +343,8 @@ async function refreshBalances( const provider = getProvider(rpcUrl, networkId); const tracked = trackedTokens || []; const trackedSet = new Set(tracked.map((t) => t.address.toLowerCase())); + // The tracked tokens whose balance was not read at some address. + const unread = new Set(); const updates = []; for (const wallet of wallets) { @@ -406,12 +419,14 @@ async function refreshBalances( signal, ), explorerRequest, - ]).then(([trackedRows, explorerRows]) => { + ]).then(([trackedResult, explorerRows]) => { + for (const lower of trackedResult.unread) unread.add(lower); const untrackedRows = (explorerRows ?? previous).filter( (row) => !trackedSet.has(String(row.address).toLowerCase()), ); - addr.tokenBalances = trackedRows.concat(untrackedRows); + addr.tokenBalances = + trackedResult.rows.concat(untrackedRows); log.debugf( "Token balances", addr.address, @@ -425,6 +440,7 @@ async function refreshBalances( await Promise.all(updates); log.debugf("refreshBalances done"); + return [...trackedSet].filter((lower) => !unread.has(lower)); } // Drops the balance of each token not in `trackedTokens` from every address. diff --git a/tests/addedTokenBalance.test.js b/tests/addedTokenBalance.test.js new file mode 100644 index 0000000..46058cc --- /dev/null +++ b/tests/addedTokenBalance.test.js @@ -0,0 +1,382 @@ +// After "Add" on AddToken, AddressDetail lists the new token. Its line reads +// `quantity unknown`, not 0.0000, until a balance refresh has read the token's +// balance at every address, even if the popup closes and opens again +// meanwhile. A token added on SettingsAddToken reads the same wherever it is +// listed. A refresh that returns draws the balances on AddressDetail or +// AddressToken again, so the fetched balance shows without the screen being +// reopened (https://git.eeqj.de/sneak/AutistMask/issues/516). +// +// The tracked token's record carries `balanceNotFetched` until a refresh has +// read the token's balance. Removing the token removes that record. A network +// switch keeps each network's records apart. +// +// 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. + +const { + bootPopup, + cleanupPopup, + unversionedValidProfile, +} = require("./support/popupBoot"); + +// Tracked by nobody until a test adds it. +const NEW_TOKEN = "0xBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB"; + +// A holding of a million NEW_TOKEN, as refreshBalances() stores it. +const MILLION = { + address: NEW_TOKEN, + name: "New Token", + symbol: "NEW", + decimals: 18, + balance: "1000000.0", + holders: 3, +}; + +afterEach(() => { + cleanupPopup(); +}); + +// The popup reopened onto AddressDetail for the one address. +function bootOnAddress(extra) { + return bootPopup( + unversionedValidProfile({ + currentView: "address", + selectedWallet: 0, + selectedAddress: 0, + ...extra, + }), + ); +} + +// 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. +function holdNextRefresh(page) { + const { refreshBalances } = require("../src/shared/balances"); + let release; + refreshBalances.mockImplementationOnce( + async (wallets, rpcUrl, explorerUrl, trackedTokens) => { + const read = trackedTokens.map((t) => t.address.toLowerCase()); + const tokenBalances = await new Promise((resolve) => { + release = resolve; + }); + wallets[0].addresses[0].balance = "2.0"; + if (tokenBalances === null) return []; + wallets[0].addresses[0].tokenBalances = tokenBalances; + return read; + }, + ); + return async (tokenBalances) => { + release(tokenBalances); + await page.settle(); + }; +} + +// Runs the ten-second refresh to the end, finishing it as holdNextRefresh() +// does with `tokenBalances`. +async function refreshOnce(page, tokenBalances) { + const finish = holdNextRefresh(page); + const ticking = page.tick(); + await page.settle(); + await finish(tokenBalances); + await ticking; +} + +// "+ Token", NEW_TOKEN typed in, and "Add", with the contract lookup +// answering at once. +async function addNewToken(page) { + const { lookupTokenInfo } = require("../src/shared/balances"); + lookupTokenInfo.mockResolvedValueOnce({ + symbol: "NEW", + decimals: 18, + name: "New Token", + }); + await page.click("btn-add-token"); + page.node("add-token-address").value = NEW_TOKEN; + await page.click("btn-add-token-confirm"); +} + +// Settings' "+ Add token", NEW_TOKEN typed in, and "Add", with the contract +// lookup answering at once. It ends on SettingsAddToken. +async function addOnSettingsAddToken(page) { + const { lookupTokenInfo } = require("../src/shared/balances"); + lookupTokenInfo.mockResolvedValueOnce({ + symbol: "NEW", + decimals: 18, + name: "New Token", + }); + await page.click("btn-settings-add-token"); + page.node("settings-addtoken-address").value = NEW_TOKEN; + await page.click("btn-settings-addtoken-manual"); +} + +// The "[x]" button Settings draws beside the first tracked token. The page +// stub does not turn the markup Settings draws into elements. So the list +// hands Settings a stand-in for that button each time it is drawn, and +// `press()` presses the last one drawn. Call it before Settings opens. +function firstRemoveButton(page) { + const button = { press: null }; + page.node("settings-tracked-tokens").querySelectorAll = () => [ + { + dataset: { idx: "0" }, + addEventListener: (name, fn) => { + button.press = fn; + }, + }, + ]; + return button; +} + +// Picks `networkId` in the Settings network selector, as the user does. +async function chooseNetwork(page, networkId) { + const select = page.node("settings-network"); + select.value = networkId; + for (const fn of select.listeners.change) await fn(); + await page.settle(); +} + +// NEW_TOKEN's row in the balance list in element `id`, AddressDetail's unless +// another is named, as markup. +function newTokenRow(page, id = "address-balances") { + return page + .node(id) + .innerHTML.split("
+ row.includes('data-token="' + NEW_TOKEN.toLowerCase() + '"'), + ); +} + +// AddressDetail for the one address opened from Home, as its `[info]` button +// does. +async function openAddress(page) { + const { state } = require("../src/shared/state"); + state.selectedWallet = 0; + state.selectedAddress = 0; + require("../src/popup/views/helpers").pushCurrentView(); + require("../src/popup/views/addressDetail").show(); + await page.settle(); +} + +// AddressToken for `token` opened from AddressDetail, as tapping the token's +// row there does. +async function openToken(page, token) { + const { state } = require("../src/shared/state"); + state.selectedToken = token; + require("../src/popup/views/helpers").pushCurrentView(); + require("../src/popup/views/addressToken").show(); + await page.settle(); +} + +test("until the refresh Add started returns, the new token's line reads quantity unknown", async () => { + const page = await bootOnAddress(); + expect(page.pageErrors).toEqual([]); + const finish = holdNextRefresh(page); + + await addNewToken(page); + + expect(page.visibleViews()).toEqual(["address"]); + const row = newTokenRow(page); + expect(row).toContain("quantity unknown"); + expect(row).not.toContain("0.0000"); + await finish([MILLION]); +}); + +test("once that refresh returns, AddressDetail shows the fetched balance without being reopened", async () => { + const page = await bootOnAddress(); + const finish = holdNextRefresh(page); + await addNewToken(page); + // Stands for the transactions on the screen, which only show() replaces. + page.node("tx-list").innerHTML = "transactions on the screen"; + + await finish([MILLION]); + + expect(page.visibleViews()).toEqual(["address"]); + expect(newTokenRow(page)).toContain("1000000.0000"); + // Only the balances are drawn again, not the transaction list. + expect(page.node("tx-list").innerHTML).toBe("transactions on the screen"); +}); + +test("a refresh already running when the token is added does not count as reading its balance", async () => { + const page = await bootOnAddress(); + const finishRunning = holdNextRefresh(page); + const ticking = page.tick(); + await page.settle(); + + await addNewToken(page); + await finishRunning([]); + await ticking; + + expect(newTokenRow(page)).toContain("quantity unknown"); + + // The next one reads the token's balance, which is zero. + await refreshOnce(page, []); + + expect(newTokenRow(page)).toContain("0.0000"); +}); + +test("closed and opened again before any refresh has read its balance, the new token's line still reads quantity unknown", async () => { + const closed = await bootOnAddress(); + holdNextRefresh(closed); + await addNewToken(closed); + cleanupPopup(); + + // The refresh this boot starts reads no token's balance: the boot's stub + // of refreshBalances() reads none. + const page = await bootPopup(undefined, { storage: closed.storage }); + expect(page.visibleViews()).toEqual(["address"]); + const row = newTokenRow(page); + expect(row).toContain("quantity unknown"); + expect(row).not.toContain("0.0000"); + + await refreshOnce(page, [MILLION]); + + expect(newTokenRow(page)).toContain("1000000.0000"); +}); + +test("a refresh that could not read the new token's balance leaves its line quantity unknown", async () => { + const page = await bootOnAddress(); + const finish = holdNextRefresh(page); + await addNewToken(page); + + await finish(null); + + // Drawn again: the ETH balance it fetched is shown. + expect(page.node("address-balances").innerHTML).toContain("2.0000"); + expect(newTokenRow(page)).toContain("quantity unknown"); +}); + +test("AddressToken for the new token reads quantity unknown until the refresh returns, then the fetched balance", async () => { + const page = await bootOnAddress(); + const finish = holdNextRefresh(page); + await addNewToken(page); + + await openToken(page, NEW_TOKEN.toLowerCase()); + + 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 finish([MILLION]); + + expect(page.node("address-token-balance").innerHTML).toContain( + "1000000.0000", + ); +}); + +test("a token added by contract address on SettingsAddToken reads quantity unknown until the refresh returns", async () => { + const page = await bootPopup(unversionedValidProfile()); + expect(page.visibleViews()).toEqual(["main"]); + const finish = holdNextRefresh(page); + + await page.click("btn-settings"); + await addOnSettingsAddToken(page); + await page.click("btn-settings-addtoken-back"); + await page.click("btn-settings-back"); + expect(page.visibleViews()).toEqual(["main"]); + const onHome = newTokenRow(page, "wallet-list"); + expect(onHome).toContain("quantity unknown"); + expect(onHome).not.toContain("0.0000"); + + await openAddress(page); + const onAddress = newTokenRow(page); + expect(onAddress).toContain("quantity unknown"); + expect(onAddress).not.toContain("0.0000"); + + await finish([MILLION]); + + expect(newTokenRow(page)).toContain("1000000.0000"); +}); + +test("a refresh that returns while AddressToken is up draws its balance again", async () => { + const page = await bootPopup( + unversionedValidProfile({ + currentView: "address-token", + viewStack: ["main", "address"], + selectedWallet: 0, + selectedAddress: 0, + selectedToken: "ETH", + }), + ); + expect(page.visibleViews()).toEqual(["address-token"]); + expect(page.node("address-token-balance").innerHTML).toContain("1.5000"); + + await refreshOnce(page, null); + + expect(page.node("address-token-balance").innerHTML).toContain("2.0000"); +}); + +test("a token removed before any refresh has read its balance leaves nothing stored, and adding it again tracks it once", async () => { + const page = await bootPopup(unversionedValidProfile()); + const removeButton = firstRemoveButton(page); + const stored = () => page.storage.read("autistmask"); + + await page.click("btn-settings"); + await addOnSettingsAddToken(page); + // Settings opened again from Home draws its list with the token in it. + await page.click("btn-settings-addtoken-back"); + await page.click("btn-settings-back"); + await page.click("btn-settings"); + await removeButton.press(); + await page.settle(); + + expect(stored().trackedTokens).toEqual([]); + // Nothing in the stored record names the token any more. + expect(JSON.stringify(stored()).toLowerCase()).not.toContain( + NEW_TOKEN.toLowerCase(), + ); + + await addOnSettingsAddToken(page); + // A second add of the same token is refused. + page.node("settings-addtoken-address").value = NEW_TOKEN; + await page.click("btn-settings-addtoken-manual"); + + const added = { address: NEW_TOKEN, symbol: "NEW", decimals: 18 }; + expect(stored().trackedTokens).toEqual([ + { ...added, name: "New Token", balanceNotFetched: true }, + ]); + + await refreshOnce(page, []); + + expect(stored().trackedTokens).toEqual([{ ...added, name: "New Token" }]); +}); + +test("a refresh on another network leaves the new token reading quantity unknown on its own", async () => { + // Sepolia tracks a token at the same contract address, also just added. + const sepoliaToken = { + address: NEW_TOKEN, + symbol: "SEP", + name: "Sepolia Token", + decimals: 18, + }; + const page = await bootOnAddress({ + networkTrackedTokens: { + sepolia: [{ ...sepoliaToken, balanceNotFetched: true }], + }, + }); + const finishMainnet = holdNextRefresh(page); + await addNewToken(page); + + await page.click("btn-settings"); + await chooseNetwork(page, "sepolia"); + // The refresh Add started returns after the switch and writes nothing. + await finishMainnet([MILLION]); + // Sepolia's refresh reads the balance of its own token. + await refreshOnce(page, []); + + const stored = page.storage.read("autistmask"); + expect(stored.trackedTokens).toEqual([sepoliaToken]); + expect(stored.networkTrackedTokens.mainnet[0].balanceNotFetched).toBe(true); + + await chooseNetwork(page, "mainnet"); + await page.click("btn-settings-back"); + + expect(page.visibleViews()).toEqual(["address"]); + expect(newTokenRow(page)).toContain("quantity unknown"); + + await refreshOnce(page, [MILLION]); + + expect(newTokenRow(page)).toContain("1000000.0000"); +}); diff --git a/tests/balanceRefreshNetworkSwitch.test.js b/tests/balanceRefreshNetworkSwitch.test.js index 94b0a4a..5c2ef58 100644 --- a/tests/balanceRefreshNetworkSwitch.test.js +++ b/tests/balanceRefreshNetworkSwitch.test.js @@ -41,6 +41,7 @@ async function holdRefresh(page) { holders: null, }, ]; + return []; }); const ticking = page.tick(); await page.settle(); diff --git a/tests/blockscoutEtherscanSettings.test.js b/tests/blockscoutEtherscanSettings.test.js index 747a445..de8908a 100644 --- a/tests/blockscoutEtherscanSettings.test.js +++ b/tests/blockscoutEtherscanSettings.test.js @@ -720,6 +720,7 @@ describe("the popup", () => { tokenRow(TRACKED, "TRK"), tokenRow(UNTRACKED, "TST"), ]; + return []; }); const ticking = page.tick(); await page.settle(); @@ -764,6 +765,7 @@ describe("the popup", () => { addr.tokenBalances = after ? [tokenRow(TRACKED, "TRK"), tokenRow(UNTRACKED, "TST")] : [tokenRow(TRACKED, "TRK")]; + return []; }); anotherPageSaves(page, { useBlockscout: after }); diff --git a/tests/e2e/run.js b/tests/e2e/run.js index b0a73e6..2a19096 100644 --- a/tests/e2e/run.js +++ b/tests/e2e/run.js @@ -969,7 +969,8 @@ test("a token added by its contract address is listed on the address screen (#15 await visible(env.page, "#view-address"); // No wait: the confirm renders the balance list before it shows the - // screen, and nothing renders the list again while the screen is up. + // screen. The refresh it starts draws the list again, with the row still + // in it. const row = env.page.locator( '#address-balances [data-token="' + STUB_TOKEN.address + '"]', { hasText: STUB_TOKEN.symbol }, diff --git a/tests/firstWalletRefresh.test.js b/tests/firstWalletRefresh.test.js index 232cb30..5bd9972 100644 --- a/tests/firstWalletRefresh.test.js +++ b/tests/firstWalletRefresh.test.js @@ -48,6 +48,7 @@ async function boot(stored) { for (const wallet of wallets) { for (const addr of wallet.addresses) addr.balance = chainBalance; } + return []; }); return { page, refreshBalances }; } diff --git a/tests/flashLine.test.js b/tests/flashLine.test.js index ce00781..dd76583 100644 --- a/tests/flashLine.test.js +++ b/tests/flashLine.test.js @@ -88,6 +88,7 @@ describe.each([ showFlash: (msg) => flashes.push(msg), escapeHtml: (s) => s, goBack: () => {}, + trackToken: () => {}, })); require("../src/popup/views/" + view).init({ diff --git a/tests/support/popupBoot.js b/tests/support/popupBoot.js index 22d79b6..128d0fe 100644 --- a/tests/support/popupBoot.js +++ b/tests/support/popupBoot.js @@ -279,10 +279,11 @@ async function bootPopup(stored, options) { getAddressValue: () => ({ usd: null, partial: false }), })); // scanForAddresses() answers in the real one's shape, a scan that found - // no used address: AddWallet reads `addresses` from it. + // no used address: AddWallet reads `addresses` from it. refreshBalances() + // answers as a refresh that read no tracked token's balance. jest.doMock("../../src/shared/balances", () => ({ fetchTokenBalances: jest.fn(async () => []), - refreshBalances: jest.fn(async () => {}), + refreshBalances: jest.fn(async () => []), dropUntrackedTokens: jest.requireActual("../../src/shared/balances") .dropUntrackedTokens, lookupTokenInfo: jest.fn(async () => null), diff --git a/tests/trackedTokenBalances.test.js b/tests/trackedTokenBalances.test.js index d00291f..11245f1 100644 --- a/tests/trackedTokenBalances.test.js +++ b/tests/trackedTokenBalances.test.js @@ -307,3 +307,31 @@ test("a tracked token wearing a known symbol from another contract is not listed ); expect(first.tokenBalances).toEqual([]); }); + +test("the refresh resolves with each tracked token whose balance it read at every address", async () => { + const flaky = "0x9999999999999999999999999999999999999999"; + holdings[TOKEN] = { [FIRST]: 2000000n, [SECOND]: 0n }; + // balanceOf answers at FIRST and fails at SECOND. + holdings[flaky] = { [FIRST]: 2000000n }; + const read = await refreshBalances( + [ + { + addresses: [ + { address: FIRST, tokenBalances: [] }, + { address: SECOND, tokenBalances: [] }, + ], + }, + ], + RPC_URL, + EXPLORER_URL, + [ + TRACKED, + { address: flaky, symbol: "FLK", name: "Flaky", decimals: 6 }, + // A known symbol from another contract: its balance is not read. + { address: OTHER, symbol: "USDC", name: "Fake", decimals: 6 }, + ], + "mainnet", + new AbortController().signal, + ); + expect(read).toEqual([TOKEN]); +}); diff --git a/tests/trackedTokensPerNetwork.test.js b/tests/trackedTokensPerNetwork.test.js index 378d556..9ac16e0 100644 --- a/tests/trackedTokensPerNetwork.test.js +++ b/tests/trackedTokensPerNetwork.test.js @@ -159,7 +159,9 @@ test("a token added by contract address on Sepolia is tracked on Sepolia only", "sepolia", expect.anything(), ); - expect(stored().trackedTokens).toEqual([SEPOLIA_TOKEN]); + // No refresh has read the new token's balance yet. + const added = { ...SEPOLIA_TOKEN, balanceNotFetched: true }; + expect(stored().trackedTokens).toEqual([added]); await boot.click("btn-settings"); await chooseNetwork(boot, "mainnet"); @@ -171,7 +173,7 @@ test("a token added by contract address on Sepolia is tracked on Sepolia only", await chooseNetwork(boot, "sepolia"); expect(stored().networkId).toBe("sepolia"); - expect(stored().trackedTokens).toEqual([SEPOLIA_TOKEN]); + expect(stored().trackedTokens).toEqual([added]); const onSepolia = boot.node("settings-tracked-tokens").innerHTML; expect(onSepolia).toContain("TST"); expect(onSepolia).not.toContain("DAI");