diff --git a/README.md b/README.md index 956769b..5a2d7a7 100644 --- a/README.md +++ b/README.md @@ -1108,7 +1108,8 @@ indexes it as a real token transfer. it. AutistMask hides transactions below a configurable dust threshold (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 - about. The threshold is user-configurable in Settings. + about. The threshold is user-configurable in Settings; a threshold of `0` + hides nothing, exactly as clearing the checkbox does. - **User-configurable**: All of the above filters (known symbol verification, low-holder threshold, fraud contract blocklist, dust threshold) are settings diff --git a/TODO.md b/TODO.md index 08910e2..6e39305 100644 --- a/TODO.md +++ b/TODO.md @@ -44,6 +44,11 @@ undefined identifiers, which is how # Completed Steps +- 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 Tailwind binary instead of `npx`, `--frozen-lockfile` on `make install`, and the Makefile-only targets documented in the README diff --git a/src/popup/views/settings.js b/src/popup/views/settings.js index 576c251..a527f77 100644 --- a/src/popup/views/settings.js +++ b/src/popup/views/settings.js @@ -304,11 +304,17 @@ function init(ctx) { $("settings-dust-threshold").value = state.dustThresholdGwei; $("settings-dust-threshold").addEventListener("change", async () => { - const val = parseInt($("settings-dust-threshold").value, 10); - if (!isNaN(val) && val >= 0) { + const raw = $("settings-dust-threshold").value.trim(); + const val = Number(raw); + // 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; await saveState(); } + $("settings-dust-threshold").value = state.dustThresholdGwei; }); $("settings-utc-timestamps").checked = state.utcTimestamps; diff --git a/src/shared/transactions.js b/src/shared/transactions.js index 303579f..9941dbc 100644 --- a/src/shared/transactions.js +++ b/src/shared/transactions.js @@ -10,6 +10,14 @@ const { formatEther, formatUnits } = require("ethers"); const { log, debugFetch } = require("./log"); 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) { const parts = val.split("."); if (parts.length === 1) return val + ".0000"; @@ -30,10 +38,10 @@ function parseTx(tx, addrLower) { let exactValue = formatEther(rawWei); let rawAmount = rawWei; let rawUnit = "wei"; - let direction = from.toLowerCase() === addrLower ? "sent" : "received"; + let direction = normalizeAddress(from) === addrLower ? "sent" : "received"; let directionLabel = direction === "sent" ? "Sent" : "Received"; if (toIsContract && method && method !== "transfer") { - const token = TOKEN_BY_ADDRESS.get(to.toLowerCase()); + const token = TOKEN_BY_ADDRESS.get(normalizeAddress(to)); if (token) { symbol = token.symbol; } @@ -87,7 +95,8 @@ function parseTokenTransfer(tt, addrLower) { const to = tt.to?.hash || ""; const decimals = parseInt(tt.total?.decimals || "18", 10); const rawVal = tt.total?.value || "0"; - const direction = from.toLowerCase() === addrLower ? "sent" : "received"; + const direction = + normalizeAddress(from) === addrLower ? "sent" : "received"; const sym = tt.token?.symbol || "?"; return { hash: tt.transaction_hash, @@ -104,11 +113,9 @@ function parseTokenTransfer(tt, addrLower) { direction: direction, directionLabel: direction === "sent" ? "Sent" : "Received", isError: false, - contractAddress: ( - tt.token?.address_hash || - tt.token?.address || - "" - ).toLowerCase(), + contractAddress: normalizeAddress( + tt.token?.address_hash || tt.token?.address || "", + ), holders: parseInt(tt.token?.holders_count || "0", 10), }; } @@ -194,7 +201,7 @@ function mergeTransactions(txs, tokenTransfers) { async function fetchRecentTransactions(address, blockscoutUrl, count = 25) { log.debugf("fetchRecentTransactions", address); - const addrLower = address.toLowerCase(); + const addrLower = normalizeAddress(address); const [txResp, ttResp] = await Promise.all([ debugFetch(blockscoutUrl + "/addresses/" + address + "/transactions"), @@ -243,34 +250,38 @@ function isSpoofedSymbol(tx) { if (!KNOWN_SYMBOLS.has(symbol)) return false; const legit = KNOWN_SYMBOLS.get(symbol); if (legit === null) return true; // "ETH" as ERC-20 is always fake - return tx.contractAddress !== legit; + return normalizeAddress(tx.contractAddress) !== normalizeAddress(legit); } // Pure filter function. Takes raw transactions and filter settings, // returns { transactions, newFraudContracts }. function filterTransactions(txs, filters = {}) { const fraudSet = new Set( - (filters.fraudContracts || []).map((a) => a.toLowerCase()), + (filters.fraudContracts || []).map(normalizeAddress), ); + // 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 filtered = []; for (const tx of txs) { + const contract = normalizeAddress(tx.contractAddress); + // Always filter spoofed known symbols and record the fraud contract if (isSpoofedSymbol(tx)) { - if (tx.contractAddress && !fraudSet.has(tx.contractAddress)) { - fraudSet.add(tx.contractAddress); - newFraud.push(tx.contractAddress); + if (contract && !fraudSet.has(contract)) { + fraudSet.add(contract); + newFraud.push(contract); } continue; } // Filter fraud contracts if setting is on - if ( - filters.hideFraudContracts && - tx.contractAddress && - fraudSet.has(tx.contractAddress) - ) { + if (filters.hideFraudContracts && contract && fraudSet.has(contract)) { continue; } @@ -291,7 +302,7 @@ function filterTransactions(txs, filters = {}) { filters.hideDustTransactions && !tx.isContractCall && tx.valueGwei !== null && - tx.valueGwei < (filters.dustThresholdGwei || 100000) + tx.valueGwei < dustThresholdGwei ) { continue; } diff --git a/tests/transactions.test.js b/tests/transactions.test.js index cc68f2f..a9d51d5 100644 --- a/tests/transactions.test.js +++ b/tests/transactions.test.js @@ -329,18 +329,42 @@ describe("known-symbol spoof verification", () => { expect(result.newFraudContracts).toEqual([]); }); - // Documents current behaviour, not desired behaviour: the spoof check - // compares tx.contractAddress against a lowercased known address with - // ===, so a caller passing a checksummed address for a genuine token has - // it treated as a spoof. In the app this cannot happen because - // parseTokenTransfer lowercases, but the exported function is not - // defensive about it the way the blocklist check is. - test("current behaviour: a checksummed genuine contract is treated as a spoof", () => { - const genuineButChecksummed = tokenTx({ + // Regression guard (#179): EIP-55 mixed case is a checksum over the + // address, not part of its identity, so the contract comparison must be + // case-insensitive in both directions — a genuine token in any casing is + // genuine, and a spoof cannot escape detection by changing its casing. + test("a genuine contract in all-lowercase form is not a spoof", () => { + const tx = tokenTx({ contractAddress: USDC_CONTRACT }); + expect(filterTransactions([tx], filters()).transactions).toEqual([tx]); + }); + + test("a genuine contract in EIP-55 checksummed form is not a spoof", () => { + const tx = tokenTx({ contractAddress: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", }); - const result = filterTransactions([genuineButChecksummed], filters()); + const result = filterTransactions([tx], 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([]); + // 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]); }); // Documents current behaviour: README.md:810-814 says all four filters @@ -412,6 +436,21 @@ describe("low-holder token filtering (the 1,000-holder rule)", () => { expect(tx.holders).toBeNull(); 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", () => { @@ -575,16 +614,50 @@ describe("dust threshold filtering", () => { expect(filterTransactions([tx], filters()).transactions).toEqual([tx]); }); - // Documents current behaviour: the threshold is read as - // `filters.dustThresholdGwei || 100000`, so a user who sets the threshold - // to 0 (the natural way to ask for no dust filtering while leaving the - // toggle on) silently gets the 100,000 gwei default instead. - test("current behaviour: a threshold of 0 falls back to the 100,000 gwei default", () => { - const result = filterTransactions( - [dustOf(50)], + // Regression guard (#179): 0 is a real threshold meaning "hide nothing", + // not an absent one. It used to be swallowed by `|| 100000`, so the one + // value a user would pick to see everything was the one that did not + // work. + test("a threshold of 0 hides nothing, leaving the toggle on", () => { + const dust = dustOf(50); + const zero = dustOf(0); + 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 }), ); - expect(result.transactions).toEqual([]); + const toggleOff = filterTransactions( + [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([]); }); });