fix: tracked tokens' balances are read through the RPC endpoint (closes #669)
refreshBalances() now reads the balance of every token tracked on the active network, at every address, with balanceOf through the same provider as the ETH balance. The entry takes its holder count and decimals from Blockscout's entry for the token in the same refresh, or keeps them from before while Blockscout fails. A failed balanceOf keeps the token's previous entry, or takes Blockscout's entry when there is none, and is logged. Blockscout's answer still adds the tokens the user has not tracked, as before. The browser test for a hostile symbol now untracks the stub token first, because a tracked token is listed under the symbol it was added with. Model: opus-5-5
This commit was merged in pull request #670.
This commit is contained in:
+131
-19
@@ -1,4 +1,5 @@
|
||||
// Balance fetching: ETH balances via RPC, ERC-20 token balances via
|
||||
// Balance fetching: ETH balances and the balances of the tokens the user
|
||||
// tracks via RPC, the other ERC-20 token balances and the holder counts via
|
||||
// Blockscout, ENS reverse lookup via RPC.
|
||||
|
||||
const {
|
||||
@@ -81,7 +82,9 @@ function rawUnits(value) {
|
||||
// Fetch token balances for a single address from Blockscout.
|
||||
// Returns [{ address, name, symbol, decimals, balance, holders }].
|
||||
// Filters out spam: only shows tokens that are in the known token list,
|
||||
// explicitly tracked by the user, or have >= 1000 holders.
|
||||
// explicitly tracked by the user, or have >= 1000 holders. A tracked token's
|
||||
// balance is read through the RPC endpoint instead, and its row here gives that
|
||||
// entry its holder count and `decimals` (fetchTrackedTokenBalances()).
|
||||
//
|
||||
// `decimals` and `balance` are each null when the answer is unknown, the same
|
||||
// way `holders` already is. Absence is never filled in here: this is the
|
||||
@@ -205,8 +208,96 @@ async function fetchTokenBalances(
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch ETH balances, ENS names, and ERC-20 token balances for all addresses.
|
||||
// The entries at `address` of the tokens the user tracks, shaped as
|
||||
// fetchTokenBalances() returns them, for the tokens the address holds.
|
||||
//
|
||||
// The balance is read with balanceOf through the RPC endpoint. The name and
|
||||
// symbol are the tracked token's. `decimals` and `holders` are those of
|
||||
// Blockscout's entry for the token in `explorerRequest`, this refresh's
|
||||
// fetchTokenBalances() for the address; while Blockscout fails they are kept
|
||||
// from the token's entry in `previous`, the address's entries before this
|
||||
// refresh. The balance is stated at the scale the screens read the token at
|
||||
// (resolveTokenDecimals()): the bundled list's, then the one the tracked token
|
||||
// records, then Blockscout's, as in fetchTokenBalances().
|
||||
//
|
||||
// A token whose balanceOf fails keeps its entry in `previous`, as a failed ETH
|
||||
// balance keeps its figure, or takes Blockscout's entry from this refresh when
|
||||
// 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().
|
||||
//
|
||||
// `signal` is as for fetchTokenBalances().
|
||||
async function fetchTrackedTokenBalances(
|
||||
provider,
|
||||
address,
|
||||
trackedTokens,
|
||||
previous,
|
||||
explorerRequest,
|
||||
signal,
|
||||
) {
|
||||
const listed = trackedTokens.filter(
|
||||
(token) => !isSpoofedSymbol(token.symbol, token.address),
|
||||
);
|
||||
// Each token's balanceOf answer, or null where it failed.
|
||||
const balances = await Promise.all(
|
||||
listed.map(async (token) => {
|
||||
try {
|
||||
const contract = new Contract(
|
||||
token.address,
|
||||
ERC20_ABI,
|
||||
provider,
|
||||
);
|
||||
return await contract.balanceOf(address);
|
||||
} catch (e) {
|
||||
if (!signal?.aborted) {
|
||||
log.errorf(
|
||||
"token balance failed",
|
||||
address,
|
||||
token.address,
|
||||
e.shortMessage || e.message,
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}),
|
||||
);
|
||||
const explorerRows = await explorerRequest;
|
||||
const reported = explorerRows ?? previous;
|
||||
|
||||
const rows = [];
|
||||
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) {
|
||||
const kept = entryIn(previous) || entryIn(explorerRows || []);
|
||||
if (kept) rows.push(kept);
|
||||
return;
|
||||
}
|
||||
if (raw === 0n) return;
|
||||
const entry = entryIn(reported);
|
||||
const decimals = entry ? entry.decimals : null;
|
||||
const known = resolveTokenDecimals(lower, { trackedTokens });
|
||||
const scale = known !== null ? known : decimals;
|
||||
rows.push({
|
||||
address: token.address,
|
||||
name: token.name || "",
|
||||
symbol: token.symbol || "???",
|
||||
decimals: decimals,
|
||||
// null when nothing knows the scale: a holding whose amount
|
||||
// cannot be stated, never a zero.
|
||||
balance: scale === null ? null : formatTokenBalance(raw, scale),
|
||||
holders: entry ? entry.holders : null,
|
||||
});
|
||||
});
|
||||
return rows;
|
||||
}
|
||||
|
||||
// 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().
|
||||
async function refreshBalances(
|
||||
wallets,
|
||||
rpcUrl,
|
||||
@@ -217,6 +308,8 @@ async function refreshBalances(
|
||||
) {
|
||||
log.debugf("refreshBalances start, rpc:", urlOrigin(rpcUrl));
|
||||
const provider = getProvider(rpcUrl, networkId);
|
||||
const tracked = trackedTokens || [];
|
||||
const trackedSet = new Set(tracked.map((t) => t.address.toLowerCase()));
|
||||
const updates = [];
|
||||
|
||||
for (const wallet of wallets) {
|
||||
@@ -266,23 +359,42 @@ async function refreshBalances(
|
||||
}),
|
||||
);
|
||||
|
||||
// ERC-20 token balances via Blockscout
|
||||
// ERC-20 token balances: the tracked tokens' through the RPC
|
||||
// endpoint, and from Blockscout the tokens the user has not
|
||||
// tracked. When Blockscout fails, the untracked rows from before
|
||||
// this refresh stay. The Blockscout request is started first and
|
||||
// handed to fetchTrackedTokenBalances() too, which waits for it
|
||||
// after sending its balanceOf reads, so the two run side by side.
|
||||
const previous = addr.tokenBalances || [];
|
||||
const explorerRequest = fetchTokenBalances(
|
||||
addr.address,
|
||||
blockscoutUrl,
|
||||
tracked,
|
||||
signal,
|
||||
);
|
||||
updates.push(
|
||||
fetchTokenBalances(
|
||||
addr.address,
|
||||
blockscoutUrl,
|
||||
trackedTokens,
|
||||
signal,
|
||||
).then((balances) => {
|
||||
if (balances !== null) {
|
||||
addr.tokenBalances = balances;
|
||||
log.debugf(
|
||||
"Token balances",
|
||||
addr.address,
|
||||
balances.length,
|
||||
"tokens",
|
||||
);
|
||||
}
|
||||
Promise.all([
|
||||
fetchTrackedTokenBalances(
|
||||
provider,
|
||||
addr.address,
|
||||
tracked,
|
||||
previous,
|
||||
explorerRequest,
|
||||
signal,
|
||||
),
|
||||
explorerRequest,
|
||||
]).then(([trackedRows, explorerRows]) => {
|
||||
const untrackedRows = (explorerRows ?? previous).filter(
|
||||
(row) =>
|
||||
!trackedSet.has(String(row.address).toLowerCase()),
|
||||
);
|
||||
addr.tokenBalances = trackedRows.concat(untrackedRows);
|
||||
log.debugf(
|
||||
"Token balances",
|
||||
addr.address,
|
||||
addr.tokenBalances.length,
|
||||
"tokens",
|
||||
);
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user