fix: tracked tokens' balances are read through the RPC endpoint (closes #669) #670
@@ -845,7 +845,7 @@ src/
|
|||||||
views/ — one JS module per screen (home, send, approval, etc.)
|
views/ — one JS module per screen (home, send, approval, etc.)
|
||||||
shared/ — modules used by both popup and background
|
shared/ — modules used by both popup and background
|
||||||
alarms.js — recurring background jobs (extension alarms API)
|
alarms.js — recurring background jobs (extension alarms API)
|
||||||
balances.js — ETH + ERC-20 balance fetching via RPC + Blockscout
|
balances.js — ETH and tracked token balances via RPC, others via Blockscout
|
||||||
constants.js — chain IDs, default RPC endpoint, ERC-20 ABI
|
constants.js — chain IDs, default RPC endpoint, ERC-20 ABI
|
||||||
ens.js — ENS forward/reverse resolution (popup only)
|
ens.js — ENS forward/reverse resolution (popup only)
|
||||||
holders.js — holder-count parsing and the low-holder rule
|
holders.js — holder-count parsing and the low-holder rule
|
||||||
@@ -1379,23 +1379,30 @@ The core hierarchy is **Wallets → Addresses**:
|
|||||||
- The user can have multiple wallets, each with multiple addresses (HD) or a
|
- The user can have multiple wallets, each with multiple addresses (HD) or a
|
||||||
single address (key).
|
single address (key).
|
||||||
|
|
||||||
Which tokens an address shows is decided by `fetchTokenBalances()` in
|
Which tokens an address shows is decided on every refresh by `refreshBalances()`
|
||||||
`src/shared/balances.js`, from the Blockscout `token-balances` response, so
|
in `src/shared/balances.js`. The balance of each token tracked on the active
|
||||||
tokens do appear without the user adding them. An ERC-20 is shown when its
|
network is read with `balanceOf` through the RPC endpoint, for every address.
|
||||||
balance is nonzero and it is in the bundled known-token list, is tracked by the
|
Its holder count and `decimals` are those of Blockscout's entry for it in the
|
||||||
user, or has 1,000 or more holders; a token claiming a symbol from the bundled
|
same refresh, kept from before while Blockscout fails. A read that fails keeps
|
||||||
list from any other contract address is always dropped, and so is any token
|
that token's previous entry, as a failed ETH balance keeps its figure, or takes
|
||||||
claiming a symbol that belongs to the native asset and therefore has no
|
Blockscout's entry from the same refresh when there is none. Every other token
|
||||||
legitimate contract at all (`"ETH"`, and every network's `nativeCurrency`, such
|
comes from the Blockscout `token-balances` response (`fetchTokenBalances()`), so
|
||||||
as `"SepoliaETH"`, on every network). That filter is unconditional — the "Hide
|
tokens do appear without the user adding them, and they keep their previous
|
||||||
tokens with fewer than 1,000 holders" setting governs the transaction history
|
figures while Blockscout fails. A tracked token is shown when its balance is
|
||||||
and the send-screen token selector, not this list. A token's holder count is
|
nonzero, and any other ERC-20 when its balance is nonzero and it is in the
|
||||||
unknown when the explorer reports none, or reports anything other than a whole
|
bundled known-token list or has 1,000 or more holders. A token claiming a symbol
|
||||||
number written in digits alone, such as `1,000` or `1e3` (`parseHoldersCount()`
|
from the bundled list from any other contract address is always dropped, tracked
|
||||||
in `src/shared/holders.js`). This list does not take an unknown count as 1,000
|
or not, and so is any token claiming a symbol that belongs to the native asset
|
||||||
or more, so such a token is shown only when it is on the bundled list or
|
and therefore has no legitimate contract at all (`"ETH"`, and every network's
|
||||||
tracked. `fetchTokenBalances()` stores every nonzero holding of a token it
|
`nativeCurrency`, such as `"SepoliaETH"`, on every network). That filter is
|
||||||
admits, however small, but a holding below 0.000001 is left out of the balance
|
unconditional — the "Hide tokens with fewer than 1,000 holders" setting governs
|
||||||
|
the transaction history and the send-screen token selector, not this list. A
|
||||||
|
token's holder count is unknown when the explorer reports none, or reports
|
||||||
|
anything other than a whole number written in digits alone, such as `1,000` or
|
||||||
|
`1e3` (`parseHoldersCount()` in `src/shared/holders.js`). This list does not
|
||||||
|
take an unknown count as 1,000 or more, so such a token is shown only when it is
|
||||||
|
on the bundled list or tracked. Every nonzero holding of a token shown is stored
|
||||||
|
exactly, however small, but a holding below 0.000001 is left out of the balance
|
||||||
lists, the send-screen token selector, the address total and the remove-address
|
lists, the send-screen token selector, the address total and the remove-address
|
||||||
warning (`isBelowOneMillionth()` in `src/shared/amountDisplay.js`). The Send and
|
warning (`isBelowOneMillionth()` in `src/shared/amountDisplay.js`). The Send and
|
||||||
confirmation screens show it when its token is the one being sent. Tracked
|
confirmation screens show it when its token is the one being sent. Tracked
|
||||||
@@ -2662,7 +2669,8 @@ AutistMask is not a fully self-contained offline tool. It necessarily
|
|||||||
communicates with three external services to function as a wallet:
|
communicates with three external services to function as a wallet:
|
||||||
|
|
||||||
- **Ethereum JSON-RPC endpoint**: The extension needs an Ethereum node to query
|
- **Ethereum JSON-RPC endpoint**: The extension needs an Ethereum node to query
|
||||||
balances (`eth_getBalance`), read ERC-20 token contracts (`eth_call`),
|
balances (`eth_getBalance`), read ERC-20 token contracts (`eth_call`), among
|
||||||
|
them the balance of every tracked token at every address (`balanceOf`),
|
||||||
estimate gas (`eth_estimateGas`), fetch nonces (`eth_getTransactionCount`),
|
estimate gas (`eth_estimateGas`), fetch nonces (`eth_getTransactionCount`),
|
||||||
broadcast transactions (`eth_sendRawTransaction`), and check transaction
|
broadcast transactions (`eth_sendRawTransaction`), and check transaction
|
||||||
receipts. The default endpoint is a public RPC (configurable by the user to
|
receipts. The default endpoint is a public RPC (configurable by the user to
|
||||||
|
|||||||
+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.
|
// Blockscout, ENS reverse lookup via RPC.
|
||||||
|
|
||||||
const {
|
const {
|
||||||
@@ -81,7 +82,9 @@ function rawUnits(value) {
|
|||||||
// Fetch token balances for a single address from Blockscout.
|
// Fetch token balances for a single address from Blockscout.
|
||||||
// Returns [{ address, name, symbol, decimals, balance, holders }].
|
// Returns [{ address, name, symbol, decimals, balance, holders }].
|
||||||
// Filters out spam: only shows tokens that are in the known token list,
|
// 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
|
// `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
|
// 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().
|
// `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(
|
async function refreshBalances(
|
||||||
wallets,
|
wallets,
|
||||||
rpcUrl,
|
rpcUrl,
|
||||||
@@ -217,6 +308,8 @@ async function refreshBalances(
|
|||||||
) {
|
) {
|
||||||
log.debugf("refreshBalances start, rpc:", urlOrigin(rpcUrl));
|
log.debugf("refreshBalances start, rpc:", urlOrigin(rpcUrl));
|
||||||
const provider = getProvider(rpcUrl, networkId);
|
const provider = getProvider(rpcUrl, networkId);
|
||||||
|
const tracked = trackedTokens || [];
|
||||||
|
const trackedSet = new Set(tracked.map((t) => t.address.toLowerCase()));
|
||||||
const updates = [];
|
const updates = [];
|
||||||
|
|
||||||
for (const wallet of wallets) {
|
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(
|
updates.push(
|
||||||
fetchTokenBalances(
|
Promise.all([
|
||||||
addr.address,
|
fetchTrackedTokenBalances(
|
||||||
blockscoutUrl,
|
provider,
|
||||||
trackedTokens,
|
addr.address,
|
||||||
signal,
|
tracked,
|
||||||
).then((balances) => {
|
previous,
|
||||||
if (balances !== null) {
|
explorerRequest,
|
||||||
addr.tokenBalances = balances;
|
signal,
|
||||||
log.debugf(
|
),
|
||||||
"Token balances",
|
explorerRequest,
|
||||||
addr.address,
|
]).then(([trackedRows, explorerRows]) => {
|
||||||
balances.length,
|
const untrackedRows = (explorerRows ?? previous).filter(
|
||||||
"tokens",
|
(row) =>
|
||||||
);
|
!trackedSet.has(String(row.address).toLowerCase()),
|
||||||
}
|
);
|
||||||
|
addr.tokenBalances = trackedRows.concat(untrackedRows);
|
||||||
|
log.debugf(
|
||||||
|
"Token balances",
|
||||||
|
addr.address,
|
||||||
|
addr.tokenBalances.length,
|
||||||
|
"tokens",
|
||||||
|
);
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,6 +18,11 @@ const {
|
|||||||
const RPC_URL = "https://rpc.example.invalid";
|
const RPC_URL = "https://rpc.example.invalid";
|
||||||
const EXPLORER_URL = "https://explorer.example.invalid/api/v2";
|
const EXPLORER_URL = "https://explorer.example.invalid/api/v2";
|
||||||
const ADDRESS = "0x1111111111111111111111111111111111111111";
|
const ADDRESS = "0x1111111111111111111111111111111111111111";
|
||||||
|
const TRACKED = {
|
||||||
|
address: "0x7777777777777777777777777777777777777777",
|
||||||
|
symbol: "TRK",
|
||||||
|
decimals: 6,
|
||||||
|
};
|
||||||
|
|
||||||
const realFetch = globalThis.fetch;
|
const realFetch = globalThis.fetch;
|
||||||
let logged;
|
let logged;
|
||||||
@@ -30,9 +35,9 @@ beforeEach(() => {
|
|||||||
const failedToFetch = async () => {
|
const failedToFetch = async () => {
|
||||||
throw new TypeError("Failed to fetch");
|
throw new TypeError("Failed to fetch");
|
||||||
};
|
};
|
||||||
// The RPC calls (ETH balance, ENS name, and the scan's balance and
|
// The RPC calls (ETH balance, ENS name, the tracked token's balance, and
|
||||||
// transaction count) and the explorer request (token balances) all fail
|
// the scan's balance and transaction count) and the explorer request (the
|
||||||
// the same way.
|
// other token balances) all fail the same way.
|
||||||
FetchRequest.registerGetUrl(failedToFetch);
|
FetchRequest.registerGetUrl(failedToFetch);
|
||||||
globalThis.fetch = jest.fn(failedToFetch);
|
globalThis.fetch = jest.fn(failedToFetch);
|
||||||
});
|
});
|
||||||
@@ -49,7 +54,7 @@ function refresh(signal) {
|
|||||||
wallets,
|
wallets,
|
||||||
RPC_URL,
|
RPC_URL,
|
||||||
EXPLORER_URL,
|
EXPLORER_URL,
|
||||||
[],
|
[TRACKED],
|
||||||
"mainnet",
|
"mainnet",
|
||||||
signal,
|
signal,
|
||||||
);
|
);
|
||||||
@@ -60,6 +65,7 @@ test("a failure while the popup is open is reported", async () => {
|
|||||||
for (const label of [
|
for (const label of [
|
||||||
"ETH balance failed",
|
"ETH balance failed",
|
||||||
"ENS reverse failed",
|
"ENS reverse failed",
|
||||||
|
"token balance failed",
|
||||||
"fetchTokenBalances failed: Failed to fetch",
|
"fetchTokenBalances failed: Failed to fetch",
|
||||||
]) {
|
]) {
|
||||||
expect(logged.some((line) => line.includes(label))).toBe(true);
|
expect(logged.some((line) => line.includes(label))).toBe(true);
|
||||||
|
|||||||
@@ -3480,6 +3480,24 @@ function hostileSymbolState(page, tokenAddress) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
test("a token whose symbol() returns markup renders as text (#307)", async (env) => {
|
test("a token whose symbol() returns markup renders as text (#307)", async (env) => {
|
||||||
|
// The stub token is no longer tracked from here on, so the explorer is
|
||||||
|
// what lists it, as it lists a token airdropped to the victim. A tracked
|
||||||
|
// token is listed from the RPC endpoint under the symbol it was added
|
||||||
|
// with, which the hostile symbol below never reaches
|
||||||
|
// (https://git.eeqj.de/sneak/AutistMask/issues/669).
|
||||||
|
await env.page.click("#btn-settings");
|
||||||
|
await visible(env.page, "#view-settings");
|
||||||
|
await env.page.click("#settings-tracked-tokens .btn-remove-token");
|
||||||
|
// The list is drawn again once the removal is saved.
|
||||||
|
await env.page.waitForFunction(
|
||||||
|
() =>
|
||||||
|
document
|
||||||
|
.getElementById("settings-tracked-tokens")
|
||||||
|
.innerText.trim() === "None",
|
||||||
|
);
|
||||||
|
await env.page.click("#btn-settings-back");
|
||||||
|
await visible(env.page, "#view-address");
|
||||||
|
|
||||||
env.routeOpts.ethBalanceWei = toHexWei(FUNDED_ETH_WEI);
|
env.routeOpts.ethBalanceWei = toHexWei(FUNDED_ETH_WEI);
|
||||||
env.routeOpts.seedTokenBalance = true;
|
env.routeOpts.seedTokenBalance = true;
|
||||||
env.routeOpts.tokenSymbolOverride = HOSTILE_SYMBOL;
|
env.routeOpts.tokenSymbolOverride = HOSTILE_SYMBOL;
|
||||||
|
|||||||
@@ -0,0 +1,303 @@
|
|||||||
|
// The balance of each token the user tracks is read with balanceOf through the
|
||||||
|
// RPC endpoint, for every address. Blockscout's answer gives a tracked token its
|
||||||
|
// holder count and decimals, and adds the tokens the user has not tracked
|
||||||
|
// (https://git.eeqj.de/sneak/AutistMask/issues/669).
|
||||||
|
//
|
||||||
|
// The real refreshBalances() runs against a fake JSON-RPC node, answered
|
||||||
|
// through ethers' own request hook, and a fake Blockscout, answered through
|
||||||
|
// fetch().
|
||||||
|
|
||||||
|
const { AbiCoder, FetchRequest } = require("ethers");
|
||||||
|
const { refreshBalances } = require("../src/shared/balances");
|
||||||
|
|
||||||
|
const RPC_URL = "https://rpc.example.invalid";
|
||||||
|
const EXPLORER_URL = "https://explorer.example.invalid/api/v2";
|
||||||
|
const FIRST = "0x1111111111111111111111111111111111111111";
|
||||||
|
const SECOND = "0x2222222222222222222222222222222222222222";
|
||||||
|
|
||||||
|
// A token the user tracks, outside the bundled list, at 6 decimals.
|
||||||
|
const TOKEN = "0x7777777777777777777777777777777777777777";
|
||||||
|
const TRACKED = {
|
||||||
|
address: TOKEN,
|
||||||
|
symbol: "TRK",
|
||||||
|
name: "Tracked Token",
|
||||||
|
decimals: 6,
|
||||||
|
};
|
||||||
|
|
||||||
|
// USDC, from the bundled list, which the user does not track.
|
||||||
|
const USDC = "0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48";
|
||||||
|
// Neither on the bundled list nor tracked.
|
||||||
|
const OTHER = "0x8888888888888888888888888888888888888888";
|
||||||
|
|
||||||
|
const ZERO_WORD = "0x" + "0".repeat(64);
|
||||||
|
const BALANCE_OF = "0x70a08231";
|
||||||
|
|
||||||
|
// What balanceOf answers, by token contract and then by holder. A pair that is
|
||||||
|
// not here makes balanceOf fail.
|
||||||
|
let holdings;
|
||||||
|
// What Blockscout answers for every address; null makes the request fail.
|
||||||
|
let explorerRows;
|
||||||
|
let logged;
|
||||||
|
|
||||||
|
// One JSON-RPC answer. A call that is not balanceOf is the ENS reverse lookup,
|
||||||
|
// answered with no resolver, so no name.
|
||||||
|
function reply(req) {
|
||||||
|
const envelope = { jsonrpc: "2.0", id: req.id };
|
||||||
|
if (req.method === "eth_getBalance") {
|
||||||
|
return { ...envelope, result: "0x0" };
|
||||||
|
}
|
||||||
|
if (req.method !== "eth_call") {
|
||||||
|
return {
|
||||||
|
...envelope,
|
||||||
|
error: { code: -32601, message: "method not found" },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const { to, data } = req.params[0];
|
||||||
|
if (!data.startsWith(BALANCE_OF)) return { ...envelope, result: ZERO_WORD };
|
||||||
|
const holder = "0x" + data.slice(-40);
|
||||||
|
const amount = (holdings[to.toLowerCase()] || {})[holder];
|
||||||
|
if (amount === undefined) {
|
||||||
|
return {
|
||||||
|
...envelope,
|
||||||
|
error: { code: -32000, message: "fixture: balanceOf failed" },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
...envelope,
|
||||||
|
result: AbiCoder.defaultAbiCoder().encode(["uint256"], [amount]),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const realFetch = globalThis.fetch;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
holdings = {};
|
||||||
|
explorerRows = null;
|
||||||
|
logged = [];
|
||||||
|
jest.spyOn(console, "error").mockImplementation((...args) => {
|
||||||
|
logged.push(args.map(String).join(" "));
|
||||||
|
});
|
||||||
|
FetchRequest.registerGetUrl(async (req) => {
|
||||||
|
const body = JSON.parse(Buffer.from(req.body).toString("utf8"));
|
||||||
|
const answer = Array.isArray(body) ? body.map(reply) : reply(body);
|
||||||
|
return {
|
||||||
|
statusCode: 200,
|
||||||
|
statusMessage: "OK",
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
body: Buffer.from(JSON.stringify(answer)),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
globalThis.fetch = jest.fn(async () => {
|
||||||
|
if (explorerRows === null) throw new TypeError("Failed to fetch");
|
||||||
|
return { ok: true, status: 200, json: async () => explorerRows };
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
FetchRequest.registerGetUrl(FetchRequest.createGetUrlFunc());
|
||||||
|
globalThis.fetch = realFetch;
|
||||||
|
jest.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
// One Blockscout token-balances row.
|
||||||
|
function explorerRow(address, symbol, value, holders = "50000") {
|
||||||
|
return {
|
||||||
|
value: String(value),
|
||||||
|
token: {
|
||||||
|
type: "ERC-20",
|
||||||
|
address_hash: address,
|
||||||
|
symbol: symbol,
|
||||||
|
name: symbol,
|
||||||
|
decimals: "6",
|
||||||
|
holders_count: holders,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// The row refreshBalances() writes for TOKEN from a balanceOf answer, with the
|
||||||
|
// decimals and holder count Blockscout reported for it, if any.
|
||||||
|
function trackedRow(balance, decimals = null, holders = null) {
|
||||||
|
return {
|
||||||
|
address: TOKEN,
|
||||||
|
name: "Tracked Token",
|
||||||
|
symbol: "TRK",
|
||||||
|
decimals: decimals,
|
||||||
|
balance: balance,
|
||||||
|
holders: holders,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refresh(addresses, trackedTokens) {
|
||||||
|
await refreshBalances(
|
||||||
|
[{ addresses }],
|
||||||
|
RPC_URL,
|
||||||
|
EXPLORER_URL,
|
||||||
|
trackedTokens,
|
||||||
|
"mainnet",
|
||||||
|
new AbortController().signal,
|
||||||
|
);
|
||||||
|
return addresses;
|
||||||
|
}
|
||||||
|
|
||||||
|
test("with Blockscout failing, a tracked token's balance is read through the RPC endpoint for every address", async () => {
|
||||||
|
holdings[TOKEN] = { [FIRST]: 1234500000n, [SECOND]: 1n };
|
||||||
|
const [first, second] = await refresh(
|
||||||
|
[
|
||||||
|
{ address: FIRST, tokenBalances: [] },
|
||||||
|
{ address: SECOND, tokenBalances: [] },
|
||||||
|
],
|
||||||
|
[TRACKED],
|
||||||
|
);
|
||||||
|
expect(first.tokenBalances).toEqual([trackedRow("1234.5")]);
|
||||||
|
// Exact, never cut: one base unit is not zero.
|
||||||
|
expect(second.tokenBalances).toEqual([trackedRow("0.000001")]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("with Blockscout failing, the untracked rows from before stay", async () => {
|
||||||
|
holdings[TOKEN] = { [FIRST]: 2000000n };
|
||||||
|
const usdc = {
|
||||||
|
address: USDC,
|
||||||
|
name: "USD Coin",
|
||||||
|
symbol: "USDC",
|
||||||
|
decimals: 6,
|
||||||
|
balance: "5.0",
|
||||||
|
holders: 50000,
|
||||||
|
};
|
||||||
|
const [first] = await refresh(
|
||||||
|
[{ address: FIRST, tokenBalances: [usdc] }],
|
||||||
|
[TRACKED],
|
||||||
|
);
|
||||||
|
expect(first.tokenBalances).toEqual([trackedRow("2.0"), usdc]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a tracked token Blockscout also reports shows the RPC figure, and an untracked one is admitted as before", async () => {
|
||||||
|
holdings[TOKEN] = { [FIRST]: 2000000n };
|
||||||
|
explorerRows = [
|
||||||
|
explorerRow(TOKEN, "TRK", 9000000),
|
||||||
|
explorerRow(USDC, "USDC", 5000000),
|
||||||
|
// Too few holders, and neither on the bundled list nor tracked.
|
||||||
|
explorerRow(OTHER, "SPAM", 5000000, "10"),
|
||||||
|
// A known symbol from a contract it does not belong to.
|
||||||
|
explorerRow(OTHER, "USDC", 5000000),
|
||||||
|
];
|
||||||
|
const [first] = await refresh(
|
||||||
|
[{ address: FIRST, tokenBalances: [] }],
|
||||||
|
[TRACKED],
|
||||||
|
);
|
||||||
|
expect(first.tokenBalances).toEqual([
|
||||||
|
trackedRow("2.0", 6, 50000),
|
||||||
|
{
|
||||||
|
address: USDC,
|
||||||
|
name: "USDC",
|
||||||
|
symbol: "USDC",
|
||||||
|
decimals: 6,
|
||||||
|
balance: "5.0",
|
||||||
|
holders: 50000,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a tracked token's entry carries the holder count and decimals Blockscout reports for it", async () => {
|
||||||
|
holdings[TOKEN] = { [FIRST]: 2000000n };
|
||||||
|
// Fewer holders than "Hide tokens with fewer than 1,000 holders" lets
|
||||||
|
// through to Send's token list.
|
||||||
|
explorerRows = [explorerRow(TOKEN, "TRK", 9000000, "10")];
|
||||||
|
const [first] = await refresh(
|
||||||
|
[{ address: FIRST, tokenBalances: [] }],
|
||||||
|
[TRACKED],
|
||||||
|
);
|
||||||
|
expect(first.tokenBalances).toEqual([trackedRow("2.0", 6, 10)]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("while Blockscout fails, a tracked token keeps the holder count and decimals of its entry from before", async () => {
|
||||||
|
holdings[TOKEN] = { [FIRST]: 2000000n };
|
||||||
|
const [first] = await refresh(
|
||||||
|
[{ address: FIRST, tokenBalances: [trackedRow("7.0", 6, 10)] }],
|
||||||
|
[TRACKED],
|
||||||
|
);
|
||||||
|
expect(first.tokenBalances).toEqual([trackedRow("2.0", 6, 10)]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a failed balanceOf keeps the token's entry from before, and is logged", async () => {
|
||||||
|
explorerRows = [explorerRow(TOKEN, "TRK", 9000000)];
|
||||||
|
const before = trackedRow("7.0", 6, 50000);
|
||||||
|
const [first] = await refresh(
|
||||||
|
[{ address: FIRST, tokenBalances: [before] }],
|
||||||
|
[TRACKED],
|
||||||
|
);
|
||||||
|
expect(first.tokenBalances).toEqual([before]);
|
||||||
|
expect(logged.some((line) => line.includes("token balance failed"))).toBe(
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a failed balanceOf with no entry from before takes Blockscout's entry from this refresh", async () => {
|
||||||
|
// balanceOf fails at FIRST and answers at SECOND.
|
||||||
|
holdings[TOKEN] = { [SECOND]: 2000000n };
|
||||||
|
explorerRows = [explorerRow(TOKEN, "TRK", 9000000)];
|
||||||
|
const [first, second] = await refresh(
|
||||||
|
[
|
||||||
|
{ address: FIRST, tokenBalances: [] },
|
||||||
|
{ address: SECOND, tokenBalances: [] },
|
||||||
|
],
|
||||||
|
[TRACKED],
|
||||||
|
);
|
||||||
|
expect(first.tokenBalances).toEqual([
|
||||||
|
{
|
||||||
|
address: TOKEN,
|
||||||
|
name: "TRK",
|
||||||
|
symbol: "TRK",
|
||||||
|
decimals: 6,
|
||||||
|
balance: "9.0",
|
||||||
|
holders: 50000,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
// Where balanceOf answers, its figure stands.
|
||||||
|
expect(second.tokenBalances).toEqual([trackedRow("2.0", 6, 50000)]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a balance of zero read through the RPC endpoint replaces the previous figure", async () => {
|
||||||
|
holdings[TOKEN] = { [FIRST]: 0n };
|
||||||
|
const [first] = await refresh(
|
||||||
|
[{ address: FIRST, tokenBalances: [trackedRow("7.0")] }],
|
||||||
|
[TRACKED],
|
||||||
|
);
|
||||||
|
expect(first.tokenBalances).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a tracked token whose scale nothing knows gets no quantity, not a zero", async () => {
|
||||||
|
holdings[TOKEN] = { [FIRST]: 5n };
|
||||||
|
const [first] = await refresh(
|
||||||
|
[{ address: FIRST, tokenBalances: [] }],
|
||||||
|
[{ ...TRACKED, decimals: null }],
|
||||||
|
);
|
||||||
|
expect(first.tokenBalances).toEqual([trackedRow(null)]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a tracked token whose scale only Blockscout reports is stated at that scale", async () => {
|
||||||
|
holdings[TOKEN] = { [FIRST]: 2000000n };
|
||||||
|
explorerRows = [explorerRow(TOKEN, "TRK", 9000000)];
|
||||||
|
const [first] = await refresh(
|
||||||
|
[{ address: FIRST, tokenBalances: [] }],
|
||||||
|
[{ ...TRACKED, decimals: null }],
|
||||||
|
);
|
||||||
|
expect(first.tokenBalances).toEqual([trackedRow("2.0", 6, 50000)]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a tracked token wearing a known symbol from another contract is not listed", async () => {
|
||||||
|
holdings[OTHER] = { [FIRST]: 5000000n };
|
||||||
|
// Its entry from before goes too, although Blockscout fails.
|
||||||
|
const before = {
|
||||||
|
address: OTHER,
|
||||||
|
name: "Fake",
|
||||||
|
symbol: "USDC",
|
||||||
|
decimals: 6,
|
||||||
|
balance: "5.0",
|
||||||
|
holders: 50000,
|
||||||
|
};
|
||||||
|
const [first] = await refresh(
|
||||||
|
[{ address: FIRST, tokenBalances: [before] }],
|
||||||
|
[{ address: OTHER, symbol: "USDC", name: "Fake", decimals: 6 }],
|
||||||
|
);
|
||||||
|
expect(first.tokenBalances).toEqual([]);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user