Files
AutistMask/tests/holders.test.js
T
sneak d63489fc5c
check / check (push) Waiting to run
e2e / e2e-chrome (push) Waiting to run
e2e / e2e-firefox (push) Waiting to run
fix: Blockscout and Etherscan lookups are off until the user turns them on (closes #668)
Out of the box the wallet sent the user's addresses to Blockscout and each
recipient to Etherscan. Two settings, "Use Blockscout" and "Check recipients on
Etherscan", now gate those requests. Both are off in a new install and offered
unticked on Welcome and in Settings. While one is off, the screens that relied
on it say so in a plain line. Each gated request reads its setting from storage
before it asks and when the answer lands, and drops a late answer. An open popup
takes another page's change and draws its screens again. While "Use Blockscout"
is off, no address keeps an untracked token, and no screen names a token from
Blockscout's report. ENS lookups ask only the RPC endpoint.

Model: opus-5-5 (implementation); fable-5-1 (prose)
2026-10-26 14:29:55 +00:00

204 lines
7.3 KiB
JavaScript

// Tests for src/shared/holders.js and the balance-list spam gate that reads
// it (issue #230).
//
// The rule these pin down: an explorer that reports no holders_count has told
// us nothing, and "nothing" must not be recorded as "zero holders". Zero is
// the strongest spam signal the wallet has, so handing it out for free turns
// a missing field into a hidden asset.
jest.mock("../src/shared/log", () => ({
log: {
debugf: () => {},
infof: () => {},
warnf: () => {},
errorf: () => {},
},
debugFetch: jest.fn(),
setRuntimeDebug: () => {},
isDebug: () => false,
}));
global.fetch = jest.fn(() => {
throw new Error("tests must not perform network requests");
});
// Blockscout is asked only while the stored "Use Blockscout" is on.
const { makeStorageStub } = require("./support/storageStub");
global.chrome = {
storage: makeStorageStub({ autistmask: { useBlockscout: true } }),
};
const {
LOW_HOLDER_THRESHOLD,
parseHoldersCount,
isLowHolderCount,
} = require("../src/shared/holders");
const { fetchTokenBalances } = require("../src/shared/balances");
const { debugFetch } = require("../src/shared/log");
const BLOCKSCOUT = "https://eth.blockscout.com/api/v2";
const HOLDER = "0x66133e8ea0f5d1d612d2502a968757d1048c214a";
const USDC_CONTRACT = "0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48";
const NOVEL_TOKEN = "0x1111111111111111111111111111111111111111";
describe("parseHoldersCount", () => {
test("a reported count parses to that number", () => {
expect(parseHoldersCount("3500000")).toBe(3500000);
expect(parseHoldersCount(3500000)).toBe(3500000);
});
test('a reported "0" parses to 0, which is not null', () => {
expect(parseHoldersCount("0")).toBe(0);
expect(parseHoldersCount(0)).toBe(0);
});
test("an omitted, null or empty count is unknown", () => {
expect(parseHoldersCount(undefined)).toBeNull();
expect(parseHoldersCount(null)).toBeNull();
expect(parseHoldersCount("")).toBeNull();
});
test("an unparseable count is unknown rather than zero", () => {
expect(parseHoldersCount("many")).toBeNull();
expect(parseHoldersCount(NaN)).toBeNull();
});
// Each of these starts with a digit, so reading only the leading digits
// would turn it into a small reported count, and a small count is
// exactly what hides a token as spam (issue #251).
test.each(["1,000", "0x10", "1e3", "12 holders"])(
"%p is not read in part: it is unknown",
(raw) => {
expect(parseHoldersCount(raw)).toBeNull();
},
);
test("a negative count is unknown", () => {
expect(parseHoldersCount("-5")).toBeNull();
expect(parseHoldersCount(-5)).toBeNull();
});
// A number holds a whole number exactly only up to 2^53 - 1. Past that a
// string of digits would come back rounded, and a long enough one as
// Infinity, which would pass every holder-count floor.
test("a count too large for a number to hold exactly is unknown", () => {
expect(parseHoldersCount("9007199254740993")).toBeNull();
expect(parseHoldersCount("9".repeat(400))).toBeNull();
expect(parseHoldersCount(2 ** 53)).toBeNull();
});
test("the largest count a number holds exactly still parses", () => {
expect(parseHoldersCount("9007199254740991")).toBe(
Number.MAX_SAFE_INTEGER,
);
expect(parseHoldersCount(Number.MAX_SAFE_INTEGER)).toBe(
Number.MAX_SAFE_INTEGER,
);
});
});
describe("isLowHolderCount", () => {
test("the threshold is the documented 1,000 holders", () => {
expect(LOW_HOLDER_THRESHOLD).toBe(1000);
});
test("a reported count below the threshold is low", () => {
expect(isLowHolderCount(0)).toBe(true);
expect(isLowHolderCount(999)).toBe(true);
});
test("a reported count at or above the threshold is not low", () => {
expect(isLowHolderCount(1000)).toBe(false);
expect(isLowHolderCount(1001)).toBe(false);
});
test("an unknown count is not low", () => {
expect(isLowHolderCount(null)).toBe(false);
expect(isLowHolderCount(undefined)).toBe(false);
});
});
// fetchTokenBalances applies its own spam gate, which is not the low-holder
// display filter: it has no setting behind it and decides what the balance
// list contains at all. It stays strict on an unknown count — see the
// comment at the gate — but must stop recording that unknown as zero.
describe("the balance-list spam gate", () => {
function respondWith(items) {
debugFetch.mockImplementation(async () => ({
ok: true,
status: 200,
statusText: "OK",
json: async () => items,
}));
}
function item(overrides = {}) {
const { token, ...rest } = overrides;
return {
value: "12500000",
...rest,
token: {
type: "ERC-20",
address_hash: NOVEL_TOKEN,
symbol: "SPAMTKN",
name: "Spam Token",
decimals: "6",
holders_count: "50000",
...token,
},
};
}
beforeEach(() => {
debugFetch.mockReset();
});
test("a token with plenty of reported holders is listed", async () => {
respondWith([item()]);
const balances = await fetchTokenBalances(HOLDER, BLOCKSCOUT, []);
expect(balances).toHaveLength(1);
expect(balances[0].holders).toBe(50000);
});
test("a token reporting zero holders is still excluded", async () => {
respondWith([item({ token: { holders_count: "0" } })]);
expect(await fetchTokenBalances(HOLDER, BLOCKSCOUT, [])).toEqual([]);
});
test("an unknown holder count does not admit an unvouched token", async () => {
respondWith([item({ token: { holders_count: null } })]);
expect(await fetchTokenBalances(HOLDER, BLOCKSCOUT, [])).toEqual([]);
});
// The path that reaches the send selector and the history filter: a token
// the user vouched for by tracking it is listed whatever the explorer
// says, and it must carry the unknown count through as null, not as the
// zero that would then hide it downstream.
test("a tracked token with an unknown count is listed with holders null", async () => {
respondWith([item({ token: { holders_count: undefined } })]);
const balances = await fetchTokenBalances(HOLDER, BLOCKSCOUT, [
{ address: NOVEL_TOKEN.toUpperCase() },
]);
expect(balances).toHaveLength(1);
expect(balances[0].holders).toBeNull();
});
test("a known-list token with an unknown count is listed with holders null", async () => {
respondWith([
item({
token: {
address_hash: USDC_CONTRACT,
symbol: "USDC",
holders_count: null,
},
}),
]);
const balances = await fetchTokenBalances(HOLDER, BLOCKSCOUT, []);
expect(balances).toHaveLength(1);
expect(balances[0].holders).toBeNull();
});
test("no test in this file performed a network request", () => {
expect(global.fetch).not.toHaveBeenCalled();
});
});