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");