Compare commits

...

2 Commits

Author SHA1 Message Date
clawbot
512b25c3da fix: move the UTC Timestamps checkbox into the Display well (closes #212)
All checks were successful
check / check (push) Successful in 31s
The `settings-utc-timestamps` checkbox sat inside the Token Spam Protection
well, immediately after the dust-threshold input. It is a display preference
and has nothing to do with spam filtering, so it now renders in the Display
well next to the theme selector.

Markup move only. The checkbox is wired by id — `$("settings-utc-timestamps")`
in `src/popup/views/settings.js`, where `$` is `document.getElementById` — for
both the initial `checked` assignment and the `change` handler, so nothing in
the wiring depends on the element's parent or siblings. `state.utcTimestamps`,
`saveState`/`loadState` and the `helpers.js` formatting path are untouched.

Both Settings descriptions follow the markup: the `README.md` Screen Map and
the `docs/README.md` Settings list each now name the UTC control under Display
and no longer under Token Spam Protection.

`tests/settingsUtcTimestamps.test.js` pins both halves down: the checkbox
appears exactly once and inside the Display well rather than the spam well,
and the setting still round-trips through storage.
2026-08-11 13:18:00 +00:00
12acf4dc8c fix: honour a dust threshold of 0 and compare addresses case-insensitively (closes #179)
Some checks failed
check / check (push) Has been cancelled
2026-08-11 15:16:48 +02:00
8 changed files with 262 additions and 53 deletions

View File

@@ -711,8 +711,8 @@ screen, including ExportPrivKey, falls back to Home.
`[x]` delete button, plus a "+ Add wallet" button `[x]` delete button, plus a "+ Add wallet" button
- Tracked Tokens: one row per tracked token with an `[x]` remove button, - Tracked Tokens: one row per tracked token with an `[x]` remove button,
plus a "+ Add token" button plus a "+ Add token" button
- Display: "Show tracked tokens with zero balance" checkbox and a Theme - Display: "Show tracked tokens with zero balance" checkbox, "UTC
selector (System / Light / Dark) Timestamps" checkbox, and a Theme selector (System / Light / Dark)
- Network: network selector (Ethereum Mainnet / Sepolia Testnet); switching - Network: network selector (Ethereum Mainnet / Sepolia Testnet); switching
resets the RPC and Blockscout endpoints to that network's defaults resets the RPC and Blockscout endpoints to that network's defaults
- Ethereum RPC: endpoint URL input + "Save" button (validated against - Ethereum RPC: endpoint URL input + "Save" button (validated against
@@ -723,7 +723,6 @@ screen, including ExportPrivKey, falls back to Home.
- "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
- "UTC Timestamps" checkbox
- Allowed Sites: list with remove buttons - Allowed Sites: list with remove buttons
- Denied Sites: list with remove buttons - Denied Sites: list with remove buttons
- About: project link, license, author, version, release date, and the - About: project link, license, author, version, release date, and the
@@ -1108,7 +1107,8 @@ 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. 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, - **User-configurable**: All of the above filters (known symbol verification,
low-holder threshold, fraud contract blocklist, dust threshold) are settings low-holder threshold, fraud contract blocklist, dust threshold) are settings

View File

@@ -44,6 +44,14 @@ undefined identifiers, which is how
# Completed Steps # Completed Steps
- 2026-08-11: UTC Timestamps checkbox moved from the Token Spam Protection well
into Display, next to the theme selector
([#212](https://git.eeqj.de/sneak/AutistMask/issues/212)).
- 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

@@ -359,16 +359,16 @@ Click the gear icon on the home screen to access settings:
- **Wallets**: Your wallets, and "+ Add wallet". - **Wallets**: Your wallets, and "+ Add wallet".
- **Tracked Tokens**: The ERC-20 tokens tracked across all addresses, and "+ Add - **Tracked Tokens**: The ERC-20 tokens tracked across all addresses, and "+ Add
token". token".
- **Display**: Toggle whether tracked tokens with zero balance are shown, and - **Display**: Toggle whether tracked tokens with zero balance are shown, switch
choose the theme (System, Light, or Dark). timestamps to UTC, and choose the theme (System, Light, or Dark).
- **Network**: Switch between Ethereum Mainnet and Sepolia Testnet. Switching - **Network**: Switch between Ethereum Mainnet and Sepolia Testnet. Switching
resets the RPC and Blockscout endpoints to that network's defaults. resets the RPC and Blockscout endpoints to that network's defaults.
- **Ethereum RPC**: Change the Ethereum node endpoint. Default is a public RPC. - **Ethereum RPC**: Change the Ethereum node endpoint. Default is a public RPC.
You can use your own node for maximum privacy. You can use your own node for maximum privacy.
- **Blockscout API**: Change the Blockscout instance used for token balances and - **Blockscout API**: Change the Blockscout instance used for token balances and
transaction history. You can use a self-hosted instance. transaction history. You can use a self-hosted instance.
- **Token Spam Protection**: Toggle individual scam filters, set the dust - **Token Spam Protection**: Toggle individual scam filters and set the dust
transaction threshold, and switch timestamps to UTC. transaction threshold.
- **Allowed Sites / Denied Sites**: View and manage web3 site permissions. - **Allowed Sites / Denied Sites**: View and manage web3 site permissions.
- **About**: License, author, version, release date, and a link to the commit - **About**: License, author, version, release date, and a link to the commit
this build came from. this build came from.

View File

@@ -869,6 +869,12 @@
/> />
Show tracked tokens with zero balance Show tracked tokens with zero balance
</label> </label>
<label
class="text-xs flex items-center gap-1 cursor-pointer mb-2"
>
<input type="checkbox" id="settings-utc-timestamps" />
UTC Timestamps
</label>
<div class="text-xs flex items-center gap-1"> <div class="text-xs flex items-center gap-1">
<label for="settings-theme">Theme:</label> <label for="settings-theme">Theme:</label>
<select <select
@@ -979,12 +985,6 @@
/> />
<span class="text-xs text-muted">gwei</span> <span class="text-xs text-muted">gwei</span>
</div> </div>
<label
class="text-xs flex items-center gap-1 cursor-pointer mb-1"
>
<input type="checkbox" id="settings-utc-timestamps" />
UTC Timestamps
</label>
</div> </div>
<div class="bg-well p-3 mx-1 mb-3"> <div class="bg-well p-3 mx-1 mb-3">

View File

@@ -304,11 +304,17 @@ 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 val = parseInt($("settings-dust-threshold").value, 10); const raw = $("settings-dust-threshold").value.trim();
if (!isNaN(val) && val >= 0) { 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; 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,6 +10,14 @@ 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";
@@ -30,10 +38,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 = from.toLowerCase() === addrLower ? "sent" : "received"; let direction = normalizeAddress(from) === 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(to.toLowerCase()); const token = TOKEN_BY_ADDRESS.get(normalizeAddress(to));
if (token) { if (token) {
symbol = token.symbol; symbol = token.symbol;
} }
@@ -87,7 +95,8 @@ 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 = from.toLowerCase() === addrLower ? "sent" : "received"; const direction =
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,
@@ -104,11 +113,9 @@ function parseTokenTransfer(tt, addrLower) {
direction: direction, direction: direction,
directionLabel: direction === "sent" ? "Sent" : "Received", directionLabel: direction === "sent" ? "Sent" : "Received",
isError: false, isError: false,
contractAddress: ( contractAddress: normalizeAddress(
tt.token?.address_hash || tt.token?.address_hash || tt.token?.address || "",
tt.token?.address || ),
""
).toLowerCase(),
holders: parseInt(tt.token?.holders_count || "0", 10), holders: parseInt(tt.token?.holders_count || "0", 10),
}; };
} }
@@ -194,7 +201,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 = address.toLowerCase(); const addrLower = normalizeAddress(address);
const [txResp, ttResp] = await Promise.all([ const [txResp, ttResp] = await Promise.all([
debugFetch(blockscoutUrl + "/addresses/" + address + "/transactions"), debugFetch(blockscoutUrl + "/addresses/" + address + "/transactions"),
@@ -243,34 +250,38 @@ 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 tx.contractAddress !== legit; return normalizeAddress(tx.contractAddress) !== normalizeAddress(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((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 newFraud = [];
const filtered = []; const filtered = [];
for (const tx of txs) { for (const tx of txs) {
const contract = normalizeAddress(tx.contractAddress);
// Always filter spoofed known symbols and record the fraud contract // Always filter spoofed known symbols and record the fraud contract
if (isSpoofedSymbol(tx)) { if (isSpoofedSymbol(tx)) {
if (tx.contractAddress && !fraudSet.has(tx.contractAddress)) { if (contract && !fraudSet.has(contract)) {
fraudSet.add(tx.contractAddress); fraudSet.add(contract);
newFraud.push(tx.contractAddress); newFraud.push(contract);
} }
continue; continue;
} }
// Filter fraud contracts if setting is on // Filter fraud contracts if setting is on
if ( if (filters.hideFraudContracts && contract && fraudSet.has(contract)) {
filters.hideFraudContracts &&
tx.contractAddress &&
fraudSet.has(tx.contractAddress)
) {
continue; continue;
} }
@@ -291,7 +302,7 @@ function filterTransactions(txs, filters = {}) {
filters.hideDustTransactions && filters.hideDustTransactions &&
!tx.isContractCall && !tx.isContractCall &&
tx.valueGwei !== null && tx.valueGwei !== null &&
tx.valueGwei < (filters.dustThresholdGwei || 100000) tx.valueGwei < dustThresholdGwei
) { ) {
continue; continue;
} }

View File

@@ -0,0 +1,111 @@
// Tests for the UTC Timestamps setting.
//
// The checkbox was moved out of the Token Spam Protection well and into the
// Display well next to the theme selector. It is wired by id through the $()
// helper, so the move cannot break the handler — but nothing in the suite said
// so. These tests pin both halves down: the markup lives in Display and
// nowhere else, and the value still round-trips through storage.
const fs = require("fs");
const path = require("path");
const POPUP_HTML = fs.readFileSync(
path.join(__dirname, "..", "src", "popup", "index.html"),
"utf8",
);
// The body of one `<div class="bg-well ...">` well, selected by its heading.
function wellWithHeading(html, heading) {
const headingIndex = html.indexOf(
'<h3 class="font-bold mb-1">' + heading + "</h3>",
);
expect(headingIndex).toBeGreaterThan(-1);
const start = html.lastIndexOf('<div class="bg-well', headingIndex);
const end = html.indexOf('<div class="bg-well', headingIndex);
return html.slice(start, end === -1 ? html.length : end);
}
describe("the UTC Timestamps checkbox placement", () => {
test("the checkbox appears exactly once in the popup markup", () => {
const matches = POPUP_HTML.match(/id="settings-utc-timestamps"/g);
expect(matches).toHaveLength(1);
});
test("it renders in the Display well, alongside the theme selector", () => {
const display = wellWithHeading(POPUP_HTML, "Display");
expect(display).toContain('id="settings-utc-timestamps"');
expect(display).toContain('id="settings-theme"');
});
test("it does not render in the Token Spam Protection well", () => {
const spam = wellWithHeading(POPUP_HTML, "Token Spam Protection");
expect(spam).not.toContain('id="settings-utc-timestamps"');
// The filters that do belong there are untouched.
expect(spam).toContain('id="settings-hide-low-holders"');
expect(spam).toContain('id="settings-hide-fraud-contracts"');
expect(spam).toContain('id="settings-hide-dust"');
expect(spam).toContain('id="settings-dust-threshold"');
});
});
describe("the UTC Timestamps setting round-trips through storage", () => {
let store;
function loadStateModule() {
store = {};
global.chrome = {
storage: {
local: {
get: async (key) =>
key in store ? { [key]: store[key] } : {},
set: async (obj) => Object.assign(store, obj),
},
},
};
jest.resetModules();
return require("../src/shared/state");
}
afterEach(() => {
delete global.chrome;
});
test("defaults to off with nothing persisted", async () => {
const { state, loadState } = loadStateModule();
await loadState();
expect(state.utcTimestamps).toBe(false);
});
test("an enabled checkbox is persisted and read back", async () => {
const first = loadStateModule();
// What the change handler in views/settings.js does.
first.state.utcTimestamps = true;
await first.saveState();
expect(store.autistmask.utcTimestamps).toBe(true);
// A fresh popup load sees it.
jest.resetModules();
const second = require("../src/shared/state");
expect(second.state.utcTimestamps).toBe(false);
await second.loadState();
expect(second.state.utcTimestamps).toBe(true);
});
test("turning it back off is persisted too", async () => {
const { state, saveState, loadState } = loadStateModule();
state.utcTimestamps = true;
await saveState();
state.utcTimestamps = false;
await saveState();
state.utcTimestamps = true;
await loadState();
expect(state.utcTimestamps).toBe(false);
});
});

View File

@@ -329,18 +329,42 @@ describe("known-symbol spoof verification", () => {
expect(result.newFraudContracts).toEqual([]); expect(result.newFraudContracts).toEqual([]);
}); });
// Documents current behaviour, not desired behaviour: the spoof check // Regression guard (#179): EIP-55 mixed case is a checksum over the
// compares tx.contractAddress against a lowercased known address with // address, not part of its identity, so the contract comparison must be
// ===, so a caller passing a checksummed address for a genuine token has // case-insensitive in both directions — a genuine token in any casing is
// it treated as a spoof. In the app this cannot happen because // genuine, and a spoof cannot escape detection by changing its casing.
// parseTokenTransfer lowercases, but the exported function is not test("a genuine contract in all-lowercase form is not a spoof", () => {
// defensive about it the way the blocklist check is. const tx = tokenTx({ contractAddress: USDC_CONTRACT });
test("current behaviour: a checksummed genuine contract is treated as a spoof", () => { expect(filterTransactions([tx], filters()).transactions).toEqual([tx]);
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([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([]); 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 // 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(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", () => {
@@ -575,16 +614,50 @@ describe("dust threshold filtering", () => {
expect(filterTransactions([tx], filters()).transactions).toEqual([tx]); expect(filterTransactions([tx], filters()).transactions).toEqual([tx]);
}); });
// Documents current behaviour: the threshold is read as // Regression guard (#179): 0 is a real threshold meaning "hide nothing",
// `filters.dustThresholdGwei || 100000`, so a user who sets the threshold // not an absent one. It used to be swallowed by `|| 100000`, so the one
// to 0 (the natural way to ask for no dust filtering while leaving the // value a user would pick to see everything was the one that did not
// toggle on) silently gets the 100,000 gwei default instead. // work.
test("current behaviour: a threshold of 0 falls back to the 100,000 gwei default", () => { test("a threshold of 0 hides nothing, leaving the toggle on", () => {
const result = filterTransactions( const dust = dustOf(50);
[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 }), 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([]);
}); });
}); });