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, and Send reads its balance as unknown. refreshBalances() now resolves with the tracked tokens whose balance it read at every address. The popup's refresh deletes the field from those tokens in the stored record and leaves the rest of the stored list as it is. Removing the token removes the field, and each network keeps its own records. Model: opus-5-5
This commit is contained in:
+16
-1
@@ -6,6 +6,7 @@ const {
|
||||
saveState,
|
||||
onSaveFailure,
|
||||
takeStoredNetwork,
|
||||
clearBalanceNotFetched,
|
||||
loadState,
|
||||
} = require("../shared/state");
|
||||
const { StateUnusableError } = require("../shared/stateSchema");
|
||||
@@ -95,6 +96,17 @@ 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. The save takes the field off the
|
||||
// matching tokens of the stored record (clearBalanceNotFetched() in
|
||||
// src/shared/state.js). The rest of the stored tracked token list stays as it
|
||||
// is.
|
||||
//
|
||||
// Once the save is done, the wallet list is drawn again. So are the balances
|
||||
// on AddressDetail or AddressToken when that screen is up. No other screen is
|
||||
// drawn again, DeleteAddress and its balance lines included.
|
||||
async function doRefreshAndRender() {
|
||||
if (refreshInFlight) {
|
||||
refreshAgain = true;
|
||||
@@ -105,7 +117,7 @@ async function doRefreshAndRender() {
|
||||
await followStoredSettings();
|
||||
const networkId = state.networkId;
|
||||
const wallets = structuredClone(state.wallets);
|
||||
await Promise.all([
|
||||
const [, readTokens] = await Promise.all([
|
||||
refreshPrices(pageClosed.signal),
|
||||
refreshBalances(
|
||||
wallets,
|
||||
@@ -137,10 +149,13 @@ async function doRefreshAndRender() {
|
||||
if (!stored.useBlockscout) {
|
||||
dropUntrackedTokens(state.wallets, stored.trackedTokens);
|
||||
}
|
||||
clearBalanceNotFetched(stored, readTokens);
|
||||
state.lastBalanceRefresh = Date.now();
|
||||
return true;
|
||||
});
|
||||
renderWalletList();
|
||||
if (state.currentView === "address") addressDetail.renderBalances();
|
||||
if (state.currentView === "address-token") addressToken.renderBalance();
|
||||
} catch (e) {
|
||||
// No caller waits for this function, so it must never reject. An
|
||||
// unhandled rejection reports nothing. The save inside it reports its
|
||||
|
||||
@@ -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");
|
||||
@@ -88,7 +95,7 @@ function init(ctx) {
|
||||
return;
|
||||
}
|
||||
log.infof("Adding token", info.symbol, contractAddr);
|
||||
state.trackedTokens.push({
|
||||
trackToken({
|
||||
address: contractAddr,
|
||||
symbol: info.symbol,
|
||||
decimals: info.decimals,
|
||||
|
||||
@@ -67,11 +67,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 =
|
||||
'<div class="text-muted text-xs py-1">Loading...</div>';
|
||||
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,
|
||||
@@ -85,13 +101,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 =
|
||||
'<div class="text-muted text-xs py-1">Loading...</div>';
|
||||
showView("address");
|
||||
loadTransactions(addr.address);
|
||||
}
|
||||
|
||||
let loadedTxs = [];
|
||||
@@ -320,4 +329,4 @@ function init(_ctx) {
|
||||
exportPrivkey.init();
|
||||
}
|
||||
|
||||
module.exports = { init, show };
|
||||
module.exports = { init, show, renderBalances };
|
||||
|
||||
@@ -50,37 +50,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(tokenId);
|
||||
}
|
||||
|
||||
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");
|
||||
@@ -102,13 +83,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");
|
||||
@@ -170,6 +145,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(tokenId);
|
||||
}
|
||||
|
||||
// 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) {
|
||||
const networkId = state.networkId;
|
||||
try {
|
||||
@@ -383,4 +399,4 @@ function init(_ctx) {
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = { init, show };
|
||||
module.exports = { init, show, renderBalance };
|
||||
|
||||
@@ -418,6 +418,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(),
|
||||
@@ -453,7 +462,7 @@ function balanceLinesForAddress(addr, trackedTokens, showZero) {
|
||||
if (isSpoofedSymbol(t.symbol, t.address)) continue;
|
||||
html += balanceLine(
|
||||
t.symbol,
|
||||
0,
|
||||
t.balanceNotFetched ? null : 0,
|
||||
getPrice(t.address),
|
||||
t.address.toLowerCase(),
|
||||
);
|
||||
@@ -833,6 +842,7 @@ module.exports = {
|
||||
flashCopyFeedback,
|
||||
balanceLine,
|
||||
balanceLinesForAddress,
|
||||
trackToken,
|
||||
addressHoldsFunds,
|
||||
keyMaterialName,
|
||||
unknownableAmount,
|
||||
|
||||
+10
-2
@@ -198,13 +198,21 @@ function renderSendTokenSelect(addr) {
|
||||
// null rather than "0": this screen states it as unknown, as the confirmation
|
||||
// screen does, and Max fills nothing in for it. Only a stored quantity is
|
||||
// withdrawn: the "0" for a token that has no row at all is an absence of
|
||||
// holdings, which is true at every scale.
|
||||
// holdings, which is true at every scale. A token with no row whose tracked
|
||||
// record carries `balanceNotFetched` gets null instead, because no refresh has
|
||||
// read its balance.
|
||||
function tokenBalanceAndDecimals(addr, token) {
|
||||
const tb = (addr.tokenBalances || []).find(
|
||||
(t) => t.address.toLowerCase() === token.toLowerCase(),
|
||||
);
|
||||
const tokenDecimals = resolveTokenDecimals(token, tokenSourcesByChainId());
|
||||
if (!tb) return { tokenBalance: "0", tokenDecimals };
|
||||
if (!tb) {
|
||||
const tracked = state.trackedTokens.find(
|
||||
(t) => t.address.toLowerCase() === token.toLowerCase(),
|
||||
);
|
||||
const notRead = tracked && tracked.balanceNotFetched;
|
||||
return { tokenBalance: notRead ? null : "0", tokenDecimals };
|
||||
}
|
||||
if (tokenDecimals === null) return { tokenBalance: null, tokenDecimals };
|
||||
return { tokenBalance: tb.balance ?? null, tokenDecimals };
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
@@ -44,7 +51,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();
|
||||
@@ -111,7 +118,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();
|
||||
@@ -167,7 +174,7 @@ function init(_ctx) {
|
||||
return;
|
||||
}
|
||||
log.infof("Adding token", info.symbol, addr);
|
||||
state.trackedTokens.push({
|
||||
trackToken({
|
||||
address: addr,
|
||||
symbol: info.symbol,
|
||||
decimals: info.decimals,
|
||||
|
||||
+20
-4
@@ -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.
|
||||
|
||||
+25
-1
@@ -129,7 +129,9 @@ function currentNetwork() {
|
||||
// loadState() finished, or when its last successful saveState() began.
|
||||
// takeStoredNetwork(), takeStoredTrackedTokens() and
|
||||
// takeStoredNetworkTrackedTokens() also write into it what this page has taken
|
||||
// from the stored record since then. saveState() diffs the live state against
|
||||
// from the stored record since then. `baseline` also lacks any
|
||||
// `balanceNotFetched` field that a balance refresh has deleted since
|
||||
// (clearBalanceNotFetched()). saveState() diffs the live state against
|
||||
// `baseline` to find only the fields THIS page changed since.
|
||||
//
|
||||
// Deep-cloned, not a reference: callers mutate persisted objects and arrays
|
||||
@@ -701,6 +703,27 @@ function takeStoredNetworkTrackedTokens(stored) {
|
||||
);
|
||||
}
|
||||
|
||||
// Deletes `balanceNotFetched` from each tracked token whose lowercased
|
||||
// contract address is in `addresses`. The field goes from `stored`, from
|
||||
// `state` and from `baseline` alike. `stored` is the record a save of this
|
||||
// page has just read (doRefreshAndRender() in src/popup/index.js calls this
|
||||
// from `beforeWrite`). So the deletion is not a change this page made to its
|
||||
// tracked tokens. Unless this page has changed its tracked tokens itself, the
|
||||
// save then writes the stored list with only the field gone.
|
||||
function clearBalanceNotFetched(stored, addresses) {
|
||||
for (const list of [
|
||||
stored.trackedTokens,
|
||||
rawState.trackedTokens,
|
||||
baseline.trackedTokens,
|
||||
]) {
|
||||
for (const token of list) {
|
||||
if (addresses.includes(token.address.toLowerCase())) {
|
||||
delete token.balanceNotFetched;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Rejects with StateUnusableError for a stored record this build cannot make
|
||||
// sense of. Nothing is assigned and `loaded` stays false in that case, so a
|
||||
// caller that ignores the rejection gets StateNotLoadedError on the first
|
||||
@@ -749,6 +772,7 @@ module.exports = {
|
||||
takeStoredNetwork,
|
||||
takeStoredTrackedTokens,
|
||||
takeStoredNetworkTrackedTokens,
|
||||
clearBalanceNotFetched,
|
||||
loadState,
|
||||
currentAddress,
|
||||
currentNetwork,
|
||||
|
||||
Reference in New Issue
Block a user