Out of the box the wallet sent the user's addresses to Blockscout and each recipient's address to Etherscan. Two settings, "Use Blockscout" and "Check recipients on Etherscan", now gate every request that sends the user's data to those services. Both start off and are offered unticked on Welcome and in Settings. Each request reads its setting from storage before it asks. An answer that lands after the setting went off is dropped. While a setting is off, the screens that rely on it say so. ENS lookups ask only the RPC endpoint, so a name resolved off-chain does not resolve. RULES.md's External Communication item is now out of date. Its new wording will be proposed on #211 once this lands. Model: opus-5-5 (implementation, summary); fable-5-1 (prose)
204 lines
7.3 KiB
JavaScript
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();
|
|
});
|
|
});
|