// 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([]); });