Compare commits

..

1 Commits

Author SHA1 Message Date
clawbot
d0202fde58 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.
2026-08-11 13:07:04 +00:00
6 changed files with 47 additions and 153 deletions

View File

@@ -1087,14 +1087,9 @@ indexes it as a real token transfer.
thing that populates it. In the transaction history the check is the "Hide 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, 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 spoofed transfers are shown and no new blocklist entries are learned from
them. The send-screen token selector applies the same check unconditionally, them. The same check on the balance list and on the send-screen token selector
because it decides which tokens the user can act on rather than what the is unconditional, because those decide which tokens the user can act on rather
history displays. The balance list applies it unconditionally too, but not than what the history displays.
identically: it exempts symbols that `KNOWN_SYMBOLS` maps to `null`, and
`"ETH"` is the only one. So the fake "Ethereum" token above is filtered from
the transaction history and from the send selector, but a fake-`ETH` ERC-20
that clears the balance list's own 1,000-holder floor — or that the user
tracked manually — is still shown in the balance list.
- **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.
@@ -1121,8 +1116,7 @@ indexes it as a real token transfer.
it. AutistMask hides transactions below a configurable dust threshold it. AutistMask hides transactions below a configurable dust threshold
(default: 100,000 gwei / 0.0001 ETH). This is high enough to filter poisoning (default: 100,000 gwei / 0.0001 ETH). This is high enough to filter poisoning
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; a threshold of `0` about. The threshold is user-configurable in Settings.
hides nothing, exactly as clearing the checkbox does.
- **User-configurable**: All four filters (known symbol verification, low-holder - **User-configurable**: All four filters (known symbol verification, low-holder
threshold, fraud contract blocklist, dust threshold) are settings that default threshold, fraud contract blocklist, dust threshold) are settings that default
@@ -1130,11 +1124,9 @@ indexes it as a real token transfer.
a sharp tool — users who understand the risks can configure the wallet to show a sharp tool — users who understand the risks can configure the wallet to show
everything unfiltered, unix-style. All four settings govern the transaction everything unfiltered, unix-style. All four settings govern the transaction
history; what else each one reaches varies. The known-symbol check also runs history; what else each one reaches varies. The known-symbol check also runs
unconditionally on the send-screen token selector, and on the balance list unconditionally on the balance list and on the send-screen token selector, and
except for symbols mapped to `null` (`"ETH"` alone), which the balance list the fraud contract blocklist is applied unconditionally on that selector. The
does not filter. The fraud contract blocklist is applied unconditionally on low-holder setting also gates the send selector, while the balance list's own
that selector and is not consulted by the balance list at all. 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 1,000-holder floor is unconditional (see Data Model). The dust threshold
applies to the transaction history alone. applies to the transaction history alone.

View File

@@ -48,11 +48,6 @@ undefined identifiers, which is how
(`hideSpoofedSymbols`), on by default, governing the transaction-history (`hideSpoofedSymbols`), on by default, governing the transaction-history
filter and the fraud-contract learning it feeds filter and the fraud-contract learning it feeds
([#176](https://git.eeqj.de/sneak/AutistMask/issues/176)). ([#176](https://git.eeqj.de/sneak/AutistMask/issues/176)).
- 2026-08-11: A dust threshold of `0` now means "hide nothing" instead of
falling back to the 100,000 gwei default, and every address comparison in
`src/shared/transactions.js` goes through one case-normalising helper so a
checksummed genuine contract is no longer read as a spoof
([#179](https://git.eeqj.de/sneak/AutistMask/issues/179)).
- 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

@@ -327,10 +327,7 @@ 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" 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 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. to the fraud contract blocklist below, since detecting a spoof is what fills it.
The send token list always applies the check. Your balances apply it too, with Your balances and the send token list always apply the check.
one exception: a token claiming the symbol "ETH" is not filtered there, so a
fake "ETH" token can still show up in your balance list even though it is hidden
from your transaction history and from the send token list.
**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

@@ -310,17 +310,11 @@ function init(ctx) {
$("settings-dust-threshold").value = state.dustThresholdGwei; $("settings-dust-threshold").value = state.dustThresholdGwei;
$("settings-dust-threshold").addEventListener("change", async () => { $("settings-dust-threshold").addEventListener("change", async () => {
const raw = $("settings-dust-threshold").value.trim(); const val = parseInt($("settings-dust-threshold").value, 10);
const val = Number(raw); if (!isNaN(val) && val >= 0) {
// 0 is accepted and means "hide nothing". Empty, negative,
// fractional and non-numeric input is rejected outright rather than
// coerced, and the field is put back to the stored threshold so it
// never shows a value the wallet is not using.
if (raw !== "" && Number.isInteger(val) && val >= 0) {
state.dustThresholdGwei = val; state.dustThresholdGwei = val;
await saveState(); await saveState();
} }
$("settings-dust-threshold").value = state.dustThresholdGwei;
}); });
$("settings-utc-timestamps").checked = state.utcTimestamps; $("settings-utc-timestamps").checked = state.utcTimestamps;

View File

@@ -10,14 +10,6 @@ const { formatEther, formatUnits } = require("ethers");
const { log, debugFetch } = require("./log"); const { log, debugFetch } = require("./log");
const { KNOWN_SYMBOLS, TOKEN_BY_ADDRESS } = require("./tokenList"); const { KNOWN_SYMBOLS, TOKEN_BY_ADDRESS } = require("./tokenList");
// Ethereum addresses are case-insensitive: EIP-55 mixed case is a checksum
// over the address, not part of its identity. Every address comparison in
// this file goes through this helper, so an address arriving in checksummed
// or upper-case form can never be read as a different address.
function normalizeAddress(addr) {
return (addr || "").toLowerCase();
}
function formatTxValue(val) { function formatTxValue(val) {
const parts = val.split("."); const parts = val.split(".");
if (parts.length === 1) return val + ".0000"; if (parts.length === 1) return val + ".0000";
@@ -38,10 +30,10 @@ function parseTx(tx, addrLower) {
let exactValue = formatEther(rawWei); let exactValue = formatEther(rawWei);
let rawAmount = rawWei; let rawAmount = rawWei;
let rawUnit = "wei"; let rawUnit = "wei";
let direction = normalizeAddress(from) === addrLower ? "sent" : "received"; let direction = from.toLowerCase() === addrLower ? "sent" : "received";
let directionLabel = direction === "sent" ? "Sent" : "Received"; let directionLabel = direction === "sent" ? "Sent" : "Received";
if (toIsContract && method && method !== "transfer") { if (toIsContract && method && method !== "transfer") {
const token = TOKEN_BY_ADDRESS.get(normalizeAddress(to)); const token = TOKEN_BY_ADDRESS.get(to.toLowerCase());
if (token) { if (token) {
symbol = token.symbol; symbol = token.symbol;
} }
@@ -95,8 +87,7 @@ function parseTokenTransfer(tt, addrLower) {
const to = tt.to?.hash || ""; const to = tt.to?.hash || "";
const decimals = parseInt(tt.total?.decimals || "18", 10); const decimals = parseInt(tt.total?.decimals || "18", 10);
const rawVal = tt.total?.value || "0"; const rawVal = tt.total?.value || "0";
const direction = const direction = from.toLowerCase() === addrLower ? "sent" : "received";
normalizeAddress(from) === addrLower ? "sent" : "received";
const sym = tt.token?.symbol || "?"; const sym = tt.token?.symbol || "?";
return { return {
hash: tt.transaction_hash, hash: tt.transaction_hash,
@@ -113,9 +104,11 @@ function parseTokenTransfer(tt, addrLower) {
direction: direction, direction: direction,
directionLabel: direction === "sent" ? "Sent" : "Received", directionLabel: direction === "sent" ? "Sent" : "Received",
isError: false, isError: false,
contractAddress: normalizeAddress( contractAddress: (
tt.token?.address_hash || tt.token?.address || "", tt.token?.address_hash ||
), tt.token?.address ||
""
).toLowerCase(),
holders: parseInt(tt.token?.holders_count || "0", 10), holders: parseInt(tt.token?.holders_count || "0", 10),
}; };
} }
@@ -201,7 +194,7 @@ function mergeTransactions(txs, tokenTransfers) {
async function fetchRecentTransactions(address, blockscoutUrl, count = 25) { async function fetchRecentTransactions(address, blockscoutUrl, count = 25) {
log.debugf("fetchRecentTransactions", address); log.debugf("fetchRecentTransactions", address);
const addrLower = normalizeAddress(address); const addrLower = address.toLowerCase();
const [txResp, ttResp] = await Promise.all([ const [txResp, ttResp] = await Promise.all([
debugFetch(blockscoutUrl + "/addresses/" + address + "/transactions"), debugFetch(blockscoutUrl + "/addresses/" + address + "/transactions"),
@@ -250,21 +243,15 @@ function isSpoofedSymbol(tx) {
if (!KNOWN_SYMBOLS.has(symbol)) return false; if (!KNOWN_SYMBOLS.has(symbol)) return false;
const legit = KNOWN_SYMBOLS.get(symbol); const legit = KNOWN_SYMBOLS.get(symbol);
if (legit === null) return true; // "ETH" as ERC-20 is always fake if (legit === null) return true; // "ETH" as ERC-20 is always fake
return normalizeAddress(tx.contractAddress) !== normalizeAddress(legit); return tx.contractAddress !== legit;
} }
// Pure filter function. Takes raw transactions and filter settings, // Pure filter function. Takes raw transactions and filter settings,
// returns { transactions, newFraudContracts }. // returns { transactions, newFraudContracts }.
function filterTransactions(txs, filters = {}) { function filterTransactions(txs, filters = {}) {
const fraudSet = new Set( const fraudSet = new Set(
(filters.fraudContracts || []).map(normalizeAddress), (filters.fraudContracts || []).map((a) => a.toLowerCase()),
); );
// The dust threshold defaults only when it is unset (nullish): a
// threshold of 0 is a real value meaning "hide nothing", since no
// transaction has a value below 0 gwei. It is therefore equivalent to
// clearing the hide-dust checkbox, and the two controls cannot override
// each other in either direction.
const dustThresholdGwei = filters.dustThresholdGwei ?? 100000;
const newFraud = []; const newFraud = [];
const filtered = []; const filtered = [];
// Fail-safe, unlike the three flags below: this one is off only when the // Fail-safe, unlike the three flags below: this one is off only when the
@@ -276,19 +263,21 @@ function filterTransactions(txs, filters = {}) {
const hideSpoofed = filters.hideSpoofedSymbols !== false; const hideSpoofed = filters.hideSpoofedSymbols !== false;
for (const tx of txs) { for (const tx of txs) {
const contract = normalizeAddress(tx.contractAddress);
// Filter spoofed known symbols and record the fraud contract // Filter spoofed known symbols and record the fraud contract
if (hideSpoofed && isSpoofedSymbol(tx)) { if (hideSpoofed && isSpoofedSymbol(tx)) {
if (contract && !fraudSet.has(contract)) { if (tx.contractAddress && !fraudSet.has(tx.contractAddress)) {
fraudSet.add(contract); fraudSet.add(tx.contractAddress);
newFraud.push(contract); newFraud.push(tx.contractAddress);
} }
continue; continue;
} }
// Filter fraud contracts if setting is on // Filter fraud contracts if setting is on
if (filters.hideFraudContracts && contract && fraudSet.has(contract)) { if (
filters.hideFraudContracts &&
tx.contractAddress &&
fraudSet.has(tx.contractAddress)
) {
continue; continue;
} }
@@ -309,7 +298,7 @@ function filterTransactions(txs, filters = {}) {
filters.hideDustTransactions && filters.hideDustTransactions &&
!tx.isContractCall && !tx.isContractCall &&
tx.valueGwei !== null && tx.valueGwei !== null &&
tx.valueGwei < dustThresholdGwei tx.valueGwei < (filters.dustThresholdGwei || 100000)
) { ) {
continue; continue;
} }

View File

@@ -330,42 +330,18 @@ describe("known-symbol spoof verification", () => {
expect(result.newFraudContracts).toEqual([]); expect(result.newFraudContracts).toEqual([]);
}); });
// Regression guard (#179): EIP-55 mixed case is a checksum over the // Documents current behaviour, not desired behaviour: the spoof check
// address, not part of its identity, so the contract comparison must be // compares tx.contractAddress against a lowercased known address with
// case-insensitive in both directions — a genuine token in any casing is // ===, so a caller passing a checksummed address for a genuine token has
// genuine, and a spoof cannot escape detection by changing its casing. // it treated as a spoof. In the app this cannot happen because
test("a genuine contract in all-lowercase form is not a spoof", () => { // parseTokenTransfer lowercases, but the exported function is not
const tx = tokenTx({ contractAddress: USDC_CONTRACT }); // defensive about it the way the blocklist check is.
expect(filterTransactions([tx], filters()).transactions).toEqual([tx]); test("current behaviour: a checksummed genuine contract is treated as a spoof", () => {
}); const genuineButChecksummed = tokenTx({
test("a genuine contract in EIP-55 checksummed form is not a spoof", () => {
const tx = tokenTx({
contractAddress: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", contractAddress: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48",
}); });
const result = filterTransactions([tx], filters()); const result = filterTransactions([genuineButChecksummed], filters());
expect(result.transactions).toEqual([tx]);
expect(result.newFraudContracts).toEqual([]);
});
test("a genuine contract in all-uppercase form is not a spoof", () => {
const tx = tokenTx({
contractAddress: "0X" + USDC_CONTRACT.slice(2).toUpperCase(),
});
const result = filterTransactions([tx], filters());
expect(result.transactions).toEqual([tx]);
expect(result.newFraudContracts).toEqual([]);
});
test("a genuinely different contract claiming USDC is still a spoof in any casing", () => {
const tx = tokenTx({
contractAddress: "0xD05339F9EA5AB9D9F03B9D57F671D2ABD1F55C82",
});
const result = filterTransactions([tx], filters());
expect(result.transactions).toEqual([]); expect(result.transactions).toEqual([]);
// The recorded fraud contract is normalised, so the persisted
// blocklist matches later transfers whatever casing they arrive in.
expect(result.newFraudContracts).toEqual([FAKE_ETH_CONTRACT]);
}); });
// Turning the other three filters off must not turn this one off: each // Turning the other three filters off must not turn this one off: each
@@ -519,21 +495,6 @@ describe("low-holder token filtering (the 1,000-holder rule)", () => {
expect(tx.holders).toBeNull(); expect(tx.holders).toBeNull();
expect(filterTransactions([tx], filters()).transactions).toEqual([tx]); expect(filterTransactions([tx], filters()).transactions).toEqual([tx]);
}); });
// Regression guard (#179): an unknown holder count on a real token — the
// explorer rate-limited the call, or a self-hosted instance omits the
// field — must not be read as zero holders. Reading it that way hides a
// legitimate transfer from the user's history, the same over-filtering
// harm as the zero-threshold bug. This pins the `tx.holders !== null`
// guard, which no fixture previously reached.
test("a token whose holder count is unknown is not filtered", () => {
const tx = tokenTx({
symbol: NOVEL_SPAM_SYMBOL,
contractAddress: NOVEL_SPAM_CONTRACT,
holders: null,
});
expect(filterTransactions([tx], filters()).transactions).toEqual([tx]);
});
}); });
describe("fraud contract blocklist", () => { describe("fraud contract blocklist", () => {
@@ -697,50 +658,16 @@ describe("dust threshold filtering", () => {
expect(filterTransactions([tx], filters()).transactions).toEqual([tx]); expect(filterTransactions([tx], filters()).transactions).toEqual([tx]);
}); });
// Regression guard (#179): 0 is a real threshold meaning "hide nothing", // Documents current behaviour: the threshold is read as
// not an absent one. It used to be swallowed by `|| 100000`, so the one // `filters.dustThresholdGwei || 100000`, so a user who sets the threshold
// value a user would pick to see everything was the one that did not // to 0 (the natural way to ask for no dust filtering while leaving the
// work. // toggle on) silently gets the 100,000 gwei default instead.
test("a threshold of 0 hides nothing, leaving the toggle on", () => { test("current behaviour: a threshold of 0 falls back to the 100,000 gwei default", () => {
const dust = dustOf(50); const result = filterTransactions(
const zero = dustOf(0); [dustOf(50)],
const opts = filters({ dustThresholdGwei: 0 });
expect(filterTransactions([dust], opts).transactions).toEqual([dust]);
expect(filterTransactions([zero], opts).transactions).toEqual([zero]);
});
test("a threshold of 0 agrees with clearing the hide-dust checkbox", () => {
const tx = nativeDustTransfer();
const thresholdZero = filterTransactions(
[tx],
filters({ dustThresholdGwei: 0 }), filters({ dustThresholdGwei: 0 }),
); );
const toggleOff = filterTransactions( expect(result.transactions).toEqual([]);
[tx],
filters({ hideDustTransactions: false }),
);
expect(thresholdZero.transactions).toEqual([tx]);
expect(toggleOff.transactions).toEqual([tx]);
});
test("0, unset and a set threshold are three distinct behaviours", () => {
const tx = dustOf(50);
expect(
filterTransactions([tx], filters({ dustThresholdGwei: 0 }))
.transactions,
).toEqual([tx]);
expect(
filterTransactions([tx], filters({ dustThresholdGwei: undefined }))
.transactions,
).toEqual([]);
expect(
filterTransactions([tx], filters({ dustThresholdGwei: 40 }))
.transactions,
).toEqual([tx]);
expect(
filterTransactions([tx], filters({ dustThresholdGwei: 60 }))
.transactions,
).toEqual([]);
}); });
}); });