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
304 lines
10 KiB
JavaScript
304 lines
10 KiB
JavaScript
// 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([]);
|
|
});
|