fix: add a Settings toggle for known-symbol spoof verification (closes #176)
Some checks failed
check / check (push) Has been cancelled

The README promises all four token-spam filters "default to on but can be
individually disabled". Known-symbol spoof verification had no state flag, no
checkbox and no consulted setting: filterTransactions() applied it before any
filter setting was read, so three of the four documented filters were
configurable and the fourth was mandatory.

Adds hideSpoofedSymbols, default on, persisted and migrated so a profile
written before the setting existed loads it as on rather than undefined. The
flag is fail-safe in the pure function too: only an explicit false disables
the check, so a caller that omits the key keeps it.

Turning the setting off also stops the fraud-contract learning. That learning
is fed only by this check, and leaving it on would make the setting a no-op:
the contract it recorded would hide the very row the user asked to see, via
the fraud-contract rule that is on by default.

Scope: the setting governs the transaction history. The same check on the
balance list and the send-screen token selector stays unconditional — those
decide which tokens the user can act on, not what the history displays. The
README's user-configurable paragraph now states what each of the four
settings actually reaches, which is not uniform.

The two `current behaviour:` tests pinning the filter as undisableable are
inverted rather than deleted, and joined by coverage for the bypass, the
halted learning, the untouched sibling rules and the storage round-trip.
This commit is contained in:
clawbot
2026-08-11 13:05:40 +00:00
parent f455b0ae7f
commit d0202fde58
12 changed files with 224 additions and 28 deletions

View File

@@ -102,3 +102,60 @@ describe("loadState hasWallet reconciliation", () => {
expect(mod.state.activeAddress).toBe(ADDRESS);
});
});
// The known-symbol spoof filter is a safety filter, so an existing profile
// stored before the setting existed must load with it on rather than with
// undefined, which would read as off.
describe("hideSpoofedSymbols persistence", () => {
test("defaults to on with empty storage", async () => {
const { mod } = loadModuleWith(null);
await mod.loadState();
expect(mod.state.hideSpoofedSymbols).toBe(true);
});
test("a profile stored without the key loads with it on", async () => {
const { mod } = loadModuleWith({ wallets: oneWallet() });
await mod.loadState();
expect(mod.state.hideSpoofedSymbols).toBe(true);
});
test("an explicit false survives the load", async () => {
const { mod } = loadModuleWith({
wallets: oneWallet(),
hideSpoofedSymbols: false,
});
await mod.loadState();
expect(mod.state.hideSpoofedSymbols).toBe(false);
});
test("saveState persists the flag", async () => {
const { mod, set } = loadModuleWith(null);
mod.state.hideSpoofedSymbols = false;
await mod.saveState();
expect(set).toHaveBeenCalledWith({
autistmask: expect.objectContaining({ hideSpoofedSymbols: false }),
});
});
test("the flag round-trips off through save and load", async () => {
const first = loadModuleWith(null);
first.mod.state.hideSpoofedSymbols = false;
await first.mod.saveState();
const persisted = first.set.mock.calls[0][0].autistmask;
const second = loadModuleWith(persisted);
await second.mod.loadState();
expect(second.mod.state.hideSpoofedSymbols).toBe(false);
});
test("the flag round-trips back on through save and load", async () => {
const first = loadModuleWith(null);
first.mod.state.hideSpoofedSymbols = true;
await first.mod.saveState();
const persisted = first.set.mock.calls[0][0].autistmask;
const second = loadModuleWith(persisted);
await second.mod.loadState();
expect(second.mod.state.hideSpoofedSymbols).toBe(true);
});
});

View File

@@ -78,6 +78,7 @@ const ORDINARY_PEER = "0x5aa0f9f1e0a1d0e0e5c1e7ce3b7dbbe9c19f0a11";
// The documented default settings (README.md:810-814, state.js:24-27).
const DEFAULT_FILTERS = {
hideSpoofedSymbols: true,
hideLowHolderTokens: true,
hideFraudContracts: true,
hideDustTransactions: true,
@@ -343,30 +344,112 @@ describe("known-symbol spoof verification", () => {
expect(result.transactions).toEqual([]);
});
// Documents current behaviour: README.md:810-814 says all four filters
// "default to on but can be individually disabled". There is no setting
// for known-symbol verification, and filterTransactions applies it
// unconditionally, so it cannot be turned off.
test("current behaviour: spoof filtering cannot be disabled by any setting", () => {
const allFiltersOff = {
hideLowHolderTokens: false,
hideFraudContracts: false,
hideDustTransactions: false,
dustThresholdGwei: 1,
fraudContracts: [],
};
// Turning the other three filters off must not turn this one off: each
// filter is independent, and this is the one the README calls out as the
// defense against the fake "ETH" attack.
test("the check still runs when the other three filters are off", () => {
const result = filterTransactions(
[fakeEthTokenTransfer()],
allFiltersOff,
filters({
hideLowHolderTokens: false,
hideFraudContracts: false,
hideDustTransactions: false,
dustThresholdGwei: 1,
}),
);
expect(result.transactions).toEqual([]);
expect(result.newFraudContracts).toEqual([FAKE_ETH_CONTRACT]);
});
test("current behaviour: spoof filtering also applies with no filters argument", () => {
test("spoof filtering also applies with no filters argument", () => {
const result = filterTransactions([fakeEthTokenTransfer()]);
expect(result.transactions).toEqual([]);
});
// Fail-safe: unlike the other three flags, an absent hideSpoofedSymbols
// leaves the check ON. A caller that forgets the key keeps the wallet's
// headline protection; only a user who deliberately switched the setting
// off sends an explicit false.
test("an absent hideSpoofedSymbols leaves the check on", () => {
const result = filterTransactions([fakeEthTokenTransfer()], {
fraudContracts: [],
});
expect(result.transactions).toEqual([]);
});
test("a truthy-but-not-true hideSpoofedSymbols leaves the check on", () => {
const result = filterTransactions(
[fakeEthTokenTransfer()],
filters({ hideSpoofedSymbols: undefined }),
);
expect(result.transactions).toEqual([]);
});
});
describe("disabling known-symbol spoof verification", () => {
test("the spoofed transfer is shown when hideSpoofedSymbols is false", () => {
const attack = fakeEthTokenTransfer();
const result = filterTransactions(
[attack],
filters({
hideSpoofedSymbols: false,
// The blocklist rule would otherwise hide the same row via a
// contract this pass had already learned.
hideFraudContracts: false,
hideLowHolderTokens: false,
}),
);
expect(result.transactions).toEqual([attack]);
});
// The blocklist is populated only by this check, so switching the check
// off stops the learning too. Leaving learning on would make the setting
// a no-op: the contract it recorded would immediately hide the same row
// through the fraud-contract rule, which is on by default.
test("no fraud contract is learned when hideSpoofedSymbols is false", () => {
const result = filterTransactions(
[fakeEthTokenTransfer()],
filters({ hideSpoofedSymbols: false }),
);
expect(result.newFraudContracts).toEqual([]);
});
test("the setting off does not stop the other three rules", () => {
const dust = nativeDustTransfer();
const lowHolder = tokenTx({
symbol: NOVEL_SPAM_SYMBOL,
contractAddress: NOVEL_SPAM_CONTRACT,
holders: 0,
});
const result = filterTransactions(
[dust, lowHolder],
filters({ hideSpoofedSymbols: false }),
);
expect(result.transactions).toEqual([]);
});
// An already-persisted fraud contract keeps being filtered: the blocklist
// rule is a separate setting and is unaffected by this one.
test("an already-blocklisted contract is still hidden with the check off", () => {
const result = filterTransactions(
[fakeEthTokenTransfer()],
filters({
hideSpoofedSymbols: false,
fraudContracts: [FAKE_ETH_CONTRACT],
}),
);
expect(result.transactions).toEqual([]);
expect(result.newFraudContracts).toEqual([]);
});
test("a genuine transfer is unaffected by the setting either way", () => {
const tx = tokenTx();
expect(
filterTransactions([tx], filters({ hideSpoofedSymbols: false }))
.transactions,
).toEqual([tx]);
expect(filterTransactions([tx], filters()).transactions).toEqual([tx]);
});
});
describe("low-holder token filtering (the 1,000-holder rule)", () => {
@@ -589,7 +672,8 @@ describe("dust threshold filtering", () => {
});
describe("filter defaults promised by the README and Settings", () => {
test("all three toggles default to on and the threshold to 100,000 gwei", () => {
test("all four toggles default to on and the threshold to 100,000 gwei", () => {
expect(state.hideSpoofedSymbols).toBe(true);
expect(state.hideLowHolderTokens).toBe(true);
expect(state.hideFraudContracts).toBe(true);
expect(state.hideDustTransactions).toBe(true);
@@ -600,10 +684,10 @@ describe("filter defaults promised by the README and Settings", () => {
expect(state.fraudContracts).toEqual([]);
});
// Documents current behaviour: filterTransactions itself defaults every
// optional filter to off. The "default to on" promise is satisfied by
// the state defaults above, which every caller passes in; the pure
// function makes no assumption of its own.
// Documents current behaviour: filterTransactions defaults the other three
// optional filters to off. Their "default to on" promise is satisfied by
// the state defaults above, which every caller passes in. Spoof
// verification is the exception and stays on unless explicitly disabled.
test("current behaviour: with no filters argument only spoof filtering runs", () => {
const dust = nativeDustTransfer();
const lowHolder = tokenTx({