Compare commits

..

2 Commits

Author SHA1 Message Date
clawbot
b114151116 fix: drive background refresh and phishing update from alarms (closes #158)
Some checks failed
check / check (push) Has been cancelled
The Chrome MV3 service worker is terminated after roughly 30 seconds idle,
which destroyed both recurring jobs: the 60-second balance refresh and the
24-hour phishing blocklist refresh were setInterval schedules, so in
practice each ran only while the worker happened to be alive. The phishing
delta was persisted to localStorage, which does not exist in a service
worker, so on Chrome it was never persisted at all.

Both jobs now run off the extension alarms API in the new
src/shared/alarms.js: the browser holds the schedule and wakes the worker to
deliver it. The balance refresh is one minute and the phishing refresh is
1440 minutes, both whole minutes at or above the one-minute minimum, so
neither is silently clamped. Alarms are created only when missing or when the
existing one carries a different period, because creating one restarts its
period and the startup path runs on every wake — while an alarm left at an
older release's period would otherwise never be reconciled.

Each job's freshness guard is decoupled from its alarm period, or the period
would not be the cadence. A guard is measured from when the last run
finished, which is one run-duration after the alarm that started it, so a
guard timed to the period vetoes the very next tick and the real rate halves.
The two are handled differently because the guards differ in purpose: the
phishing cache TTL exists to keep the worker off the network on the wakes
between refreshes, so the scheduled tick bypasses it and fetches
unconditionally; the balance guard exists to skip work an open popup has
already done, so it must keep applying on the tick and is instead shortened
to half the alarm period — above the popup's 10-second refresh, below the
60-second period.

The phishing delta and the timestamps of the fetch that produced it now live
in extension storage, and updatePhishingList() reloads that record before
deciding whether a fetch is due. A revived worker therefore neither
re-fetches on every wake nor sleeps through an overdue update. A timestamp
read back from storage is discarded if it lies in the future: clock skew or a
restored profile backup would otherwise suppress updates until that time
arrived, permanently, now that the value outlives the worker.

Two timestamps are kept, not one. The 256 KiB cap still drops an oversized
delta together with its freshness claim, but the record of having contacted
the network at all is written regardless — as it is after a failed fetch —
and floors unscheduled retries at one hour. Without it, a list that is
persistently oversized or a fetch that persistently fails means a full
blocklist download on every worker wake, indefinitely.

The startup path (ensureRecurringAlarms plus the phishing list init) is
registered on onInstalled and onStartup as well as running at the top level
of the worker, and is idempotent. The concurrent callers on a fresh install
share one in-flight run rather than racing to create the same alarm, and a
failure is logged instead of becoming an unhandled rejection.

Firefox MV2 has a persistent background page where timers would have
survived, but both browsers are built from one bundle and both take the
alarm path, so there is a single code path; "alarms" is declared in both
manifests.

src/shared/ens.js keeps its localStorage cache and gains a comment recording
that it is popup-only, so it does not get pulled into the worker later.
2026-08-11 13:23:58 +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
5 changed files with 136 additions and 40 deletions

View File

@@ -1183,7 +1183,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,11 @@ undefined identifiers, which is how
# Completed Steps # 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: the balance refresh and the 24-hour phishing list refresh moved - 2026-08-11: the balance refresh and the 24-hour phishing list refresh moved
from `setInterval` to the extension alarms API, with the phishing delta and from `setInterval` to the extension alarms API, with the phishing delta and
its fetch timestamps persisted to extension storage, so neither job dies with its fetch timestamps persisted to extension storage, so neither job dies with

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

@@ -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([]);
}); });
}); });