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 86cdea5e4e
commit f4ee5f779f
12 changed files with 224 additions and 28 deletions

View File

@@ -720,6 +720,7 @@ screen, including ExportPrivKey, falls back to Home.
- Blockscout API: endpoint URL input + "Save" button (validated against - Blockscout API: endpoint URL input + "Save" button (validated against
`/stats` before being saved) `/stats` before being saved)
- Token Spam Protection: - Token Spam Protection:
- "Hide fake tokens impersonating a known symbol" checkbox
- "Hide tokens with fewer than 1,000 holders" checkbox - "Hide tokens with fewer than 1,000 holders" checkbox
- "Hide transactions from detected fraud contracts" checkbox - "Hide transactions from detected fraud contracts" checkbox
- "Hide dust transactions below N gwei" checkbox + threshold input - "Hide dust transactions below N gwei" checkbox + threshold input
@@ -1081,7 +1082,14 @@ indexes it as a real token transfer.
a spoof and filtered from display. The fake "Ethereum" token in the attack a spoof and filtered from display. The fake "Ethereum" token in the attack
above used symbol "ETH" from contract above used symbol "ETH" from contract
`0xD05339f9Ea5ab9d9F03B9d57F671d2abD1F55c82`, which does not match the known `0xD05339f9Ea5ab9d9F03B9d57F671d2abD1F55c82`, which does not match the known
WETH contract — so it would be caught by this check. WETH contract — so it would be caught by this check. Detecting a spoof is also
what adds a contract to the fraud contract blocklist below; that is the only
thing that populates it. In the transaction history the check is the "Hide
fake tokens impersonating a known symbol" setting, on by default; with it off,
spoofed transfers are shown and no new blocklist entries are learned from
them. The same check on the balance list and on the send-screen token selector
is unconditional, because those decide which tokens the user can act on rather
than what the history displays.
- **Low-holder token filtering**: Token transfers from ERC-20 contracts with - **Low-holder token filtering**: Token transfers from ERC-20 contracts with
fewer than 1,000 holders are hidden from transaction history by default. fewer than 1,000 holders are hidden from transaction history by default.
@@ -1110,11 +1118,17 @@ indexes it as a real token transfer.
dust while low enough to preserve any transfer a user would plausibly care dust while low enough to preserve any transfer a user would plausibly care
about. The threshold is user-configurable in Settings. about. The threshold is user-configurable in Settings.
- **User-configurable**: All of the above filters (known symbol verification, - **User-configurable**: All four filters (known symbol verification, low-holder
low-holder threshold, fraud contract blocklist, dust threshold) are settings threshold, fraud contract blocklist, dust threshold) are settings that default
that default to on but can be individually disabled by the user. AutistMask is to on but can be individually disabled by the user. AutistMask is designed as
designed as a sharp tool — users who understand the risks can configure the a sharp tool — users who understand the risks can configure the wallet to show
wallet to show everything unfiltered, unix-style. everything unfiltered, unix-style. All four settings govern the transaction
history; what else each one reaches varies. The known-symbol check also runs
unconditionally on the balance list and on the send-screen token selector, and
the fraud contract blocklist is applied unconditionally on that selector. The
low-holder setting also gates the send selector, while the balance list's own
1,000-holder floor is unconditional (see Data Model). The dust threshold
applies to the transaction history alone.
#### Phishing Domain Protection #### Phishing Domain Protection

View File

@@ -44,6 +44,10 @@ undefined identifiers, which is how
# Completed Steps # Completed Steps
- 2026-08-11: Known-symbol spoof verification became a Settings toggle
(`hideSpoofedSymbols`), on by default, governing the transaction-history
filter and the fraud-contract learning it feeds
([#176](https://git.eeqj.de/sneak/AutistMask/issues/176)).
- 2026-08-11: Policy compliance sweep — conditional verbose test rerun, local - 2026-08-11: Policy compliance sweep — conditional verbose test rerun, local
Tailwind binary instead of `npx`, `--frozen-lockfile` on `make install`, and Tailwind binary instead of `npx`, `--frozen-lockfile` on `make install`, and
the Makefile-only targets documented in the README the Makefile-only targets documented in the README

View File

@@ -323,7 +323,11 @@ by default:
**Known token symbol verification.** AutistMask ships a list of roughly 500 **Known token symbol verification.** AutistMask ships a list of roughly 500
legitimate ERC-20 tokens with their contract addresses. If a transaction or legitimate ERC-20 tokens with their contract addresses. If a transaction or
balance claims to involve a known symbol (like "ETH" or "USDT") but comes from balance claims to involve a known symbol (like "ETH" or "USDT") but comes from
an unrecognized contract, it is identified as a spoof and hidden. an unrecognized contract, it is identified as a spoof and hidden. In your
transaction history this is the "Hide fake tokens impersonating a known symbol"
setting, which you can switch off; doing so also stops new entries being added
to the fraud contract blocklist below, since detecting a spoof is what fills it.
Your balances and the send token list always apply the check.
**Low-holder token filtering.** Tokens with fewer than 1,000 holders are hidden **Low-holder token filtering.** Tokens with fewer than 1,000 holders are hidden
from transaction history and the send token list, and are left out of your from transaction history and the send token list, and are left out of your

View File

@@ -948,6 +948,15 @@
transfers and prevent interaction with suspicious transfers and prevent interaction with suspicious
tokens. tokens.
</p> </p>
<label
class="text-xs flex items-center gap-1 cursor-pointer mb-2"
>
<input
type="checkbox"
id="settings-hide-spoofed-symbols"
/>
Hide fake tokens impersonating a known symbol
</label>
<label <label
class="text-xs flex items-center gap-1 cursor-pointer mb-2" class="text-xs flex items-center gap-1 cursor-pointer mb-2"
> >

View File

@@ -148,6 +148,7 @@ async function loadTransactions(address) {
state.blockscoutUrl, state.blockscoutUrl,
); );
const result = filterTransactions(rawTxs, { const result = filterTransactions(rawTxs, {
hideSpoofedSymbols: state.hideSpoofedSymbols,
hideLowHolderTokens: state.hideLowHolderTokens, hideLowHolderTokens: state.hideLowHolderTokens,
hideFraudContracts: state.hideFraudContracts, hideFraudContracts: state.hideFraudContracts,
hideDustTransactions: state.hideDustTransactions, hideDustTransactions: state.hideDustTransactions,

View File

@@ -222,6 +222,7 @@ async function loadTransactions(address, tokenId) {
state.blockscoutUrl, state.blockscoutUrl,
); );
const result = filterTransactions(rawTxs, { const result = filterTransactions(rawTxs, {
hideSpoofedSymbols: state.hideSpoofedSymbols,
hideLowHolderTokens: state.hideLowHolderTokens, hideLowHolderTokens: state.hideLowHolderTokens,
hideFraudContracts: state.hideFraudContracts, hideFraudContracts: state.hideFraudContracts,
hideDustTransactions: state.hideDustTransactions, hideDustTransactions: state.hideDustTransactions,

View File

@@ -163,6 +163,7 @@ async function loadHomeTxs(ctx) {
if (allAddresses.length === 0) return; if (allAddresses.length === 0) return;
const filters = { const filters = {
hideSpoofedSymbols: state.hideSpoofedSymbols,
hideLowHolderTokens: state.hideLowHolderTokens, hideLowHolderTokens: state.hideLowHolderTokens,
hideFraudContracts: state.hideFraudContracts, hideFraudContracts: state.hideFraudContracts,
hideDustTransactions: state.hideDustTransactions, hideDustTransactions: state.hideDustTransactions,

View File

@@ -284,6 +284,12 @@ function init(ctx) {
applyTheme(state.theme); applyTheme(state.theme);
}); });
$("settings-hide-spoofed-symbols").checked = state.hideSpoofedSymbols;
$("settings-hide-spoofed-symbols").addEventListener("change", async () => {
state.hideSpoofedSymbols = $("settings-hide-spoofed-symbols").checked;
await saveState();
});
$("settings-hide-low-holders").checked = state.hideLowHolderTokens; $("settings-hide-low-holders").checked = state.hideLowHolderTokens;
$("settings-hide-low-holders").addEventListener("change", async () => { $("settings-hide-low-holders").addEventListener("change", async () => {
state.hideLowHolderTokens = $("settings-hide-low-holders").checked; state.hideLowHolderTokens = $("settings-hide-low-holders").checked;

View File

@@ -21,6 +21,7 @@ const DEFAULT_STATE = {
deniedSites: {}, deniedSites: {},
rememberSiteChoice: true, rememberSiteChoice: true,
showZeroBalanceTokens: true, showZeroBalanceTokens: true,
hideSpoofedSymbols: true,
hideLowHolderTokens: true, hideLowHolderTokens: true,
hideFraudContracts: true, hideFraudContracts: true,
hideDustTransactions: true, hideDustTransactions: true,
@@ -61,6 +62,7 @@ async function saveState() {
deniedSites: state.deniedSites, deniedSites: state.deniedSites,
rememberSiteChoice: state.rememberSiteChoice, rememberSiteChoice: state.rememberSiteChoice,
showZeroBalanceTokens: state.showZeroBalanceTokens, showZeroBalanceTokens: state.showZeroBalanceTokens,
hideSpoofedSymbols: state.hideSpoofedSymbols,
hideLowHolderTokens: state.hideLowHolderTokens, hideLowHolderTokens: state.hideLowHolderTokens,
hideFraudContracts: state.hideFraudContracts, hideFraudContracts: state.hideFraudContracts,
hideDustTransactions: state.hideDustTransactions, hideDustTransactions: state.hideDustTransactions,
@@ -112,6 +114,12 @@ async function loadState() {
saved.showZeroBalanceTokens !== undefined saved.showZeroBalanceTokens !== undefined
? saved.showZeroBalanceTokens ? saved.showZeroBalanceTokens
: true; : true;
// A profile written before this setting existed has no key for it.
// It is a safety filter, so absent must load as on, not as undefined.
state.hideSpoofedSymbols =
saved.hideSpoofedSymbols !== undefined
? saved.hideSpoofedSymbols
: true;
state.hideLowHolderTokens = state.hideLowHolderTokens =
saved.hideLowHolderTokens !== undefined saved.hideLowHolderTokens !== undefined
? saved.hideLowHolderTokens ? saved.hideLowHolderTokens

View File

@@ -254,10 +254,17 @@ function filterTransactions(txs, filters = {}) {
); );
const newFraud = []; const newFraud = [];
const filtered = []; const filtered = [];
// Fail-safe, unlike the three flags below: this one is off only when the
// caller says so explicitly, so a caller that omits the key keeps the
// check rather than silently losing it. The setting also governs the
// blocklist learning below, which exists only to serve this check —
// leaving learning on while the check is off would re-hide the very rows
// the user asked to see, through the fraud-contract rule.
const hideSpoofed = filters.hideSpoofedSymbols !== false;
for (const tx of txs) { for (const tx of txs) {
// Always filter spoofed known symbols and record the fraud contract // Filter spoofed known symbols and record the fraud contract
if (isSpoofedSymbol(tx)) { if (hideSpoofed && isSpoofedSymbol(tx)) {
if (tx.contractAddress && !fraudSet.has(tx.contractAddress)) { if (tx.contractAddress && !fraudSet.has(tx.contractAddress)) {
fraudSet.add(tx.contractAddress); fraudSet.add(tx.contractAddress);
newFraud.push(tx.contractAddress); newFraud.push(tx.contractAddress);

View File

@@ -102,3 +102,60 @@ describe("loadState hasWallet reconciliation", () => {
expect(mod.state.activeAddress).toBe(ADDRESS); 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). // The documented default settings (README.md:810-814, state.js:24-27).
const DEFAULT_FILTERS = { const DEFAULT_FILTERS = {
hideSpoofedSymbols: true,
hideLowHolderTokens: true, hideLowHolderTokens: true,
hideFraudContracts: true, hideFraudContracts: true,
hideDustTransactions: true, hideDustTransactions: true,
@@ -343,30 +344,112 @@ describe("known-symbol spoof verification", () => {
expect(result.transactions).toEqual([]); expect(result.transactions).toEqual([]);
}); });
// Documents current behaviour: README.md:810-814 says all four filters // Turning the other three filters off must not turn this one off: each
// "default to on but can be individually disabled". There is no setting // filter is independent, and this is the one the README calls out as the
// for known-symbol verification, and filterTransactions applies it // defense against the fake "ETH" attack.
// unconditionally, so it cannot be turned off. test("the check still runs when the other three filters are off", () => {
test("current behaviour: spoof filtering cannot be disabled by any setting", () => { const result = filterTransactions(
const allFiltersOff = { [fakeEthTokenTransfer()],
filters({
hideLowHolderTokens: false, hideLowHolderTokens: false,
hideFraudContracts: false, hideFraudContracts: false,
hideDustTransactions: false, hideDustTransactions: false,
dustThresholdGwei: 1, dustThresholdGwei: 1,
fraudContracts: [], }),
};
const result = filterTransactions(
[fakeEthTokenTransfer()],
allFiltersOff,
); );
expect(result.transactions).toEqual([]); expect(result.transactions).toEqual([]);
expect(result.newFraudContracts).toEqual([FAKE_ETH_CONTRACT]); 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()]); const result = filterTransactions([fakeEthTokenTransfer()]);
expect(result.transactions).toEqual([]); 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)", () => { 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", () => { 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.hideLowHolderTokens).toBe(true);
expect(state.hideFraudContracts).toBe(true); expect(state.hideFraudContracts).toBe(true);
expect(state.hideDustTransactions).toBe(true); expect(state.hideDustTransactions).toBe(true);
@@ -600,10 +684,10 @@ describe("filter defaults promised by the README and Settings", () => {
expect(state.fraudContracts).toEqual([]); expect(state.fraudContracts).toEqual([]);
}); });
// Documents current behaviour: filterTransactions itself defaults every // Documents current behaviour: filterTransactions defaults the other three
// optional filter to off. The "default to on" promise is satisfied by // optional filters to off. Their "default to on" promise is satisfied by
// the state defaults above, which every caller passes in; the pure // the state defaults above, which every caller passes in. Spoof
// function makes no assumption of its own. // verification is the exception and stays on unless explicitly disabled.
test("current behaviour: with no filters argument only spoof filtering runs", () => { test("current behaviour: with no filters argument only spoof filtering runs", () => {
const dust = nativeDustTransfer(); const dust = nativeDustTransfer();
const lowHolder = tokenTx({ const lowHolder = tokenTx({