From 6cc06b821a7564f49a47b99fad3aee54800121a1 Mon Sep 17 00:00:00 2001 From: sneak Date: Mon, 26 Oct 2026 23:12:56 +0000 Subject: [PATCH] fix: the address screen shows a token just added at its fetched balance, not 0.0000 (closes #516) A balance refresh that returns now draws the balances on AddressDetail or AddressToken again when that screen is up, not only the wallet list. Their transaction lists are left alone. Every add path on AddToken and SettingsAddToken tracks a token through trackToken(), which gives the tracked record `balanceNotFetched: true`. While the field is set, the token's line reads `quantity unknown` instead of 0.0000. The popup's refresh deletes the field once it has read the token's balance at every address. refreshBalances() now resolves with those tokens. The field is part of the tracked record, so removing the token removes it, and each network keeps its own records. Model: opus-5-5 --- README.md | 25 +- src/popup/index.js | 17 +- src/popup/views/addToken.js | 11 +- src/popup/views/addressDetail.js | 29 +- src/popup/views/addressToken.js | 80 +++-- src/popup/views/helpers.js | 12 +- src/popup/views/settingsAddToken.js | 15 +- src/shared/balances.js | 24 +- tests/addedTokenBalance.test.js | 382 ++++++++++++++++++++++ tests/balanceRefreshNetworkSwitch.test.js | 1 + tests/blockscoutEtherscanSettings.test.js | 2 + tests/e2e/run.js | 3 +- tests/firstWalletRefresh.test.js | 1 + tests/flashLine.test.js | 1 + tests/support/popupBoot.js | 5 +- tests/trackedTokenBalances.test.js | 28 ++ tests/trackedTokensPerNetwork.test.js | 6 +- 17 files changed, 580 insertions(+), 62 deletions(-) create mode 100644 tests/addedTokenBalance.test.js diff --git a/README.md b/README.md index f53e5b3..fdcbd1d 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,15 @@ 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**. Until a balance refresh on the token's network has read + the token's balance at every address, its line never reads `0.0000`. The + line reads `quantity unknown` instead, or is left out while "Show tracked + tokens with zero balance" is off. 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 +2539,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");