All checks were successful
check / check (push) Successful in 30s
The block explorer's holders_count is optional. Reading it as `holders_count || "0"` recorded a token the explorer said nothing about as a token with no holders at all, which is the strongest spam signal the wallet has: the low-holder rule then hid a legitimate transfer from the history and withheld a token the user actually holds from the Send selector. It also made the `tx.holders !== null` guard in filterTransactions unreachable for token transfers, since the coercion guaranteed a number. The null-versus-zero rule and the 1,000-holder threshold now live in one place, src/shared/holders.js, because the rule was open-coded at three call sites and got it wrong at all three. An unknown count is shown rather than hidden in both user-facing filters: hiding an asset the user owns costs more than showing a spam row they can see is unusual, and both filters have a setting behind them. The balance-list spam gate in fetchTokenBalances keeps its strict behaviour — it has no off switch and governs the whole balance list, so an unreported count is no evidence for admission — but it now records the unknown as null, so a token that reaches the list by being known or tracked is no longer hidden downstream by a zero it never reported. A reported count of zero still parses to 0 and is still filtered everywhere; that is covered by tests alongside the unknown-count ones.
167 lines
5.8 KiB
JavaScript
167 lines
5.8 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");
|
|
});
|
|
global.chrome = { storage: { local: {} } };
|
|
|
|
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();
|
|
});
|
|
});
|
|
|
|
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();
|
|
});
|
|
});
|