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. The popup's refresh deletes the field once it has read the token's balance at every address. refreshBalances() now resolves with those tokens. The field is part of the tracked record, so removing the token removes it, and each network keeps its own records. Model: opus-5-5
338 lines
11 KiB
JavaScript
338 lines
11 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 { makeStorageStub } = require("./support/storageStub");
|
|
|
|
// Blockscout is asked only while the stored "Use Blockscout" is on.
|
|
globalThis.chrome = {
|
|
storage: makeStorageStub({ autistmask: { useBlockscout: true } }),
|
|
};
|
|
|
|
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([]);
|
|
});
|
|
|
|
test("the refresh resolves with each tracked token whose balance it read at every address", async () => {
|
|
const flaky = "0x9999999999999999999999999999999999999999";
|
|
holdings[TOKEN] = { [FIRST]: 2000000n, [SECOND]: 0n };
|
|
// balanceOf answers at FIRST and fails at SECOND.
|
|
holdings[flaky] = { [FIRST]: 2000000n };
|
|
const read = await refreshBalances(
|
|
[
|
|
{
|
|
addresses: [
|
|
{ address: FIRST, tokenBalances: [] },
|
|
{ address: SECOND, tokenBalances: [] },
|
|
],
|
|
},
|
|
],
|
|
RPC_URL,
|
|
EXPLORER_URL,
|
|
[
|
|
TRACKED,
|
|
{ address: flaky, symbol: "FLK", name: "Flaky", decimals: 6 },
|
|
// A known symbol from another contract: its balance is not read.
|
|
{ address: OTHER, symbol: "USDC", name: "Fake", decimals: 6 },
|
|
],
|
|
"mainnet",
|
|
new AbortController().signal,
|
|
);
|
|
expect(read).toEqual([TOKEN]);
|
|
});
|