Compare commits

..
2 Commits
Author SHA1 Message Date
clawbot 70f0feee28 fix: open no approval window for a site-connection prompt already answered (closes #287)
check / check (push) Failing after 2s
e2e / e2e-chrome (push) Failing after 2s
e2e / e2e-firefox (push) Failing after 2s
When a site-connection prompt was decided before the toolbar popup raised
for it had loaded, that popup was torn down, chrome.action.openPopup()
rejected, and the background opened its fallback window for the answered
approval and only then removed it. In the Chrome end-to-end suite the next
test could take that window for its own prompt and lose it under its wait.
openApprovalWindow() now returns before creating a window when the approval
is no longer pending.

The blocklist test clicked its self-closing Reject with a plain click; it
now clicks it as the other site Reject does, with the click witnessed.

Model: opus-5-5
2026-10-05 01:10:10 +00:00
clawbot 6c885a0c05 harden: a holders_count that is not plain digits is unknown, not read in part (closes #251)
check / check (push) Failing after 2s
e2e / e2e-chrome (push) Failing after 2s
e2e / e2e-firefox (push) Failing after 2s
parseHoldersCount used parseInt, which reads "1,000" as 1, "0x10" as 0 and
"1e3" as 1: a reported low count, which hides the token in the transaction
history and the send-screen token selector. It now accepts only a whole
number of zero or more, or a string of digits alone, no larger than
Number.MAX_SAFE_INTEGER, and returns null for anything else. The balance
list's holders !== null check did nothing, since null >= 1000 is already
false, and is dropped. README.md and docs/README.md say how each filter
treats an unknown count and that the token screen then leaves out its
Holders row; README.md lists src/shared/holders.js.

Model: opus-5-5
2026-10-05 02:59:15 +02:00
7 changed files with 111 additions and 37 deletions
+33 -19
View File
@@ -619,13 +619,15 @@ The jobs **report, they do not gate.** A failure is a red mark against the
commit that a reviewer has to account for, not a hard block: whether a check commit that a reviewer has to account for, not a hard block: whether a check
blocks a merge is Gitea branch protection, which this repo does not configure. blocks a merge is Gitea branch protection, which this repo does not configure.
That is not only a statement about configuration. One report of the Chrome suite That is not only a statement about configuration. Reports of the Chrome suite
**failing under load** is still open: **failing under load** are still open, among them
[#290](https://git.eeqj.de/sneak/AutistMask/issues/290) records runs on a busy [#290](https://git.eeqj.de/sneak/AutistMask/issues/290), runs on a busy machine
machine failing with `the extension opened no approval window within 30000ms`. failing with `the extension opened no approval window within 30000ms`, and
So a red `e2e-chrome` has to be read before it is believed, and that report is [#446](https://git.eeqj.de/sneak/AutistMask/issues/446), the Settings round trip
the blocker to ever making this a required check. Do not answer it with a retry closing the popup before its network switch is saved. So a red `e2e-chrome` has
wrapper: a suite that reruns until it is green stops being evidence. to be read before it is believed, and those failures are the blocker to ever
making this a required check. Do not answer them with a retry wrapper: a suite
that reruns until it is green stops being evidence.
Nothing in either job can pass vacuously. There is no `continue-on-error` and no Nothing in either job can pass vacuously. There is no `continue-on-error` and no
`|| true`; both scripts exit non-zero when docker is missing, when the image `|| true`; both scripts exit non-zero when docker is missing, when the image
@@ -694,6 +696,7 @@ src/
balances.js — ETH + ERC-20 balance fetching via RPC + Blockscout balances.js — ETH + ERC-20 balance fetching via RPC + Blockscout
constants.js — chain IDs, default RPC endpoint, ERC-20 ABI constants.js — chain IDs, default RPC endpoint, ERC-20 ABI
ens.js — ENS forward/reverse resolution (popup only) ens.js — ENS forward/reverse resolution (popup only)
holders.js — holder-count parsing and the low-holder rule
prices.js — ETH/USD and token/USD via CoinDesk API prices.js — ETH/USD and token/USD via CoinDesk API
scamlist.js — known fraud contract addresses scamlist.js — known fraud contract addresses
state.js — persisted state (extension storage) state.js — persisted state (extension storage)
@@ -1122,13 +1125,18 @@ claiming a symbol that belongs to the native asset and therefore has no
legitimate contract at all (`"ETH"`, and every network's `nativeCurrency`, such legitimate contract at all (`"ETH"`, and every network's `nativeCurrency`, such
as `"SepoliaETH"`, on every network). That filter is unconditional — the "Hide as `"SepoliaETH"`, on every network). That filter is unconditional — the "Hide
tokens with fewer than 1,000 holders" setting governs the transaction history tokens with fewer than 1,000 holders" setting governs the transaction history
and the send-screen token selector, not this list. `fetchTokenBalances()` stores and the send-screen token selector, not this list. A token's holder count is
every nonzero holding of a token it admits, however small, but a holding below unknown when the explorer reports none, or reports anything other than a whole
0.000001 is left out of the balance lists, the send-screen token selector, the number written in digits alone, such as `1,000` or `1e3` (`parseHoldersCount()`
address total and the remove-address warning (`isBelowOneMillionth()` in in `src/shared/holders.js`). This list does not take an unknown count as 1,000
`src/shared/amountDisplay.js`). The Send and confirmation screens show it when or more, so such a token is shown only when it is on the bundled list or
its token is the one being sent. Tracked tokens with a zero balance are listed tracked. `fetchTokenBalances()` stores every nonzero holding of a token it
as well while "Show tracked tokens with zero balance" is on. admits, however small, but a holding below 0.000001 is left out of the balance
lists, the send-screen token selector, the address total and the remove-address
warning (`isBelowOneMillionth()` in `src/shared/amountDisplay.js`). The Send and
confirmation screens show it when its token is the one being sent. Tracked
tokens with a zero balance are listed as well while "Show tracked tokens with
zero balance" is on.
#### Stored state and its version #### Stored state and its version
@@ -1437,7 +1445,9 @@ view would leave a wallet one click from deletion.
- Send / Receive buttons - Send / Receive buttons
- Token contract well (ERC-20 only): full contract address (tap to copy, - Token contract well (ERC-20 only): full contract address (tap to copy,
etherscan link) plus name, symbol, decimals, holder count and project etherscan link) plus name, symbol, decimals, holder count and project
website where known website where known. The "Holders:" row is left out, not shown as 0, when
the token's balance-list entry has no holder count: the explorer did not
report a readable one, or the token is not in the balance list
- Token-filtered transaction list (only this token's transfers) - Token-filtered transaction list (only this token's transfers)
- **Transitions**: - **Transitions**:
- "Send" → **Send** (token locked: the dropdown is replaced by a static - "Send" → **Send** (token locked: the dropdown is replaced by a static
@@ -2388,7 +2398,8 @@ indexes it as a real token transfer.
fewer than 1,000 holders are hidden from transaction history by default. fewer than 1,000 holders are hidden from transaction history by default.
Legitimate tokens have substantial holder counts; poisoning tokens typically Legitimate tokens have substantial holder counts; poisoning tokens typically
have zero. This catches new poisoning contracts that use novel symbols not in have zero. This catches new poisoning contracts that use novel symbols not in
the known token list. the known token list. A transfer whose token's holder count is unknown (see
Data Model) is kept: only a reported count below 1,000 hides it.
- **Fraud contract blocklist**: AutistMask maintains a local list of known fraud - **Fraud contract blocklist**: AutistMask maintains a local list of known fraud
contract addresses. Token transfers involving these contracts are filtered contract addresses. Token transfers involving these contracts are filtered
@@ -2398,7 +2409,9 @@ indexes it as a real token transfer.
- **Send-side token filtering**: Tokens with fewer than 1,000 holders are - **Send-side token filtering**: Tokens with fewer than 1,000 holders are
excluded from the token selector on the send screen. This prevents users from excluded from the token selector on the send screen. This prevents users from
accidentally interacting with a spoofed token that appeared in their balance accidentally interacting with a spoofed token that appeared in their balance
via a fake Transfer event. via a fake Transfer event. A token whose holder count is unknown is kept in
the selector. The selector offers only tokens in the balance list, so such a
token is one on the bundled list or one the user tracks.
- **Dust transaction filtering**: A second wave of the same attack used real - **Dust transaction filtering**: A second wave of the same attack used real
native ETH transfers instead of fake tokens. Transaction native ETH transfers instead of fake tokens. Transaction
@@ -2422,8 +2435,9 @@ indexes it as a real token transfer.
both cases identically to the history. The fraud contract blocklist is applied both cases identically to the history. The fraud contract blocklist is applied
unconditionally on that selector and is not consulted by the balance list at unconditionally on that selector and is not consulted by the balance list at
all. The low-holder setting also gates the send selector, while the balance 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 list's own 1,000-holder floor is unconditional (see Data Model). An unknown
threshold applies to the transaction history alone. holder count passes the history and send-selector filters but not that floor.
The dust threshold applies to the transaction history alone.
#### Phishing Domain Protection #### Phishing Domain Protection
+11
View File
@@ -56,6 +56,17 @@ but the review is broader than any of them.
approval that is no longer pending. The blocklist test's Reject, whose window approval that is no longer pending. The blocklist test's Reject, whose window
closes itself, is clicked as the other site Reject is, with the click closes itself, is clicked as the other site Reject is, with the click
witnessed. witnessed.
- 2026-10-05: A `holders_count` that is not a whole number in plain digits is
unknown, not read in part
([#251](https://git.eeqj.de/sneak/AutistMask/issues/251)). `parseInt` read
`1,000` as 1, `0x10` as 0 and `1e3` as 1, a reported low count that hides the
token in the transaction history and the send-screen token selector. A count
above `Number.MAX_SAFE_INTEGER` is unknown too, not rounded or `Infinity`. The
balance list's `holders !== null` check, which did nothing, is dropped.
`README.md` and `docs/README.md` now say how each filter treats an unknown
count and that the token screen leaves out its "Holders:" row then, and
`README.md` lists `src/shared/holders.js`.
- 2026-10-04: A popup boot in the tests loads transactions without failing - 2026-10-04: A popup boot in the tests loads transactions without failing
([#429](https://git.eeqj.de/sneak/AutistMask/issues/429)). The stand-in for ([#429](https://git.eeqj.de/sneak/AutistMask/issues/429)). The stand-in for
`filterTransactions` in `tests/support/popupBoot.js` returned a bare list, `filterTransactions` in `tests/support/popupBoot.js` returned a bare list,
+7 -1
View File
@@ -333,7 +333,13 @@ it is hidden from your transaction history and from the send token list.
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
balances unless they are on the bundled known-token list or you added them balances unless they are on the bundled known-token list or you added them
yourself. Legitimate tokens have substantial holder counts; scam tokens deployed yourself. Legitimate tokens have substantial holder counts; scam tokens deployed
for address poisoning typically have zero. for address poisoning typically have zero. When the explorer reports no holder
count for a token, or reports something other than a whole number in plain
digits (such as "1,000"), the count is unknown. An unknown count does not hide a
token from your transaction history or the send token list, and it does not get
a token into your balances either: such a token is listed only if it is on the
bundled known-token list or you added it yourself. The screen you reach by
clicking a token balance shows a "Holders:" line only when the count is known.
**Fraud contract blocklist.** When AutistMask detects a fraudulent transfer, it **Fraud contract blocklist.** When AutistMask detects a fraudulent transfer, it
adds the contract address to a local blocklist. Future transactions from that adds the contract address to a local blocklist. Future transactions from that
+8 -8
View File
@@ -147,20 +147,20 @@ async function fetchTokenBalances(address, blockscoutUrl, trackedTokens) {
// null is a holding of an amount that cannot be stated, which is // null is a holding of an amount that cannot be stated, which is
// not the same as a holding of zero, and must never render as one. // not the same as a holding of zero, and must never render as one.
const bal = scale === null ? null : formatTokenBalance(raw, scale); const bal = scale === null ? null : formatTokenBalance(raw, scale);
// null means the explorer reported no count, which is not the // null means the explorer reported no readable count, which is
// same as a count of zero. This gate is not the low-holder // not the same as a count of zero. This gate is not the
// display filter: it has no user-facing off switch and governs // low-holder display filter: it has no user-facing off switch and
// the whole balance list, so it stays strict and admits a token // governs the whole balance list, so it stays strict and admits a
// only on a reported count — an unreported one is no evidence. // token only on a reported count — an unreported one is no
// A legitimate token still reaches the list through the known // evidence, and `null >= LOW_HOLDER_THRESHOLD` is false. A
// legitimate token still reaches the list through the known
// token list or by the user tracking it, and the null is carried // token list or by the user tracking it, and the null is carried
// through to the views, where the two low-holder filters treat // through to the views, where the two low-holder filters treat
// an unknown count as "do not judge" rather than as zero. // an unknown count as "do not judge" rather than as zero.
const holders = parseHoldersCount(item.token.holders_count); const holders = parseHoldersCount(item.token.holders_count);
const isKnown = TOKEN_BY_ADDRESS.has(tokenAddr); const isKnown = TOKEN_BY_ADDRESS.has(tokenAddr);
const isTracked = trackedSet.has(tokenAddr); const isTracked = trackedSet.has(tokenAddr);
const hasEnoughHolders = const hasEnoughHolders = holders >= LOW_HOLDER_THRESHOLD;
holders !== null && holders >= LOW_HOLDER_THRESHOLD;
// Skip spam tokens the user never asked to see // Skip spam tokens the user never asked to see
if (!isKnown && !isTracked && !hasEnoughHolders) continue; if (!isKnown && !isTracked && !hasEnoughHolders) continue;
+15 -6
View File
@@ -9,13 +9,22 @@
const LOW_HOLDER_THRESHOLD = 1000; const LOW_HOLDER_THRESHOLD = 1000;
// Parse an explorer-supplied holders_count into a number, or null when the // Parse an explorer-supplied holders_count into a number, or null when it is
// explorer did not report one. Anything unparseable is unknown too: a count // not one. Only a whole number of zero or more, or a string made of nothing
// we cannot read is not a count of zero. // but the digits 0-9, is a count. Anything else is null, never read in part:
// "1,000", "0x10" and "1e3" are unknown, not 1, 0 and 1, because a count we
// cannot read is not a low count. A count above Number.MAX_SAFE_INTEGER is
// null too: a number cannot hold it exactly, so it would come back rounded,
// or as Infinity.
function parseHoldersCount(raw) { function parseHoldersCount(raw) {
if (raw === null || raw === undefined || raw === "") return null; if (typeof raw === "number") {
const n = parseInt(raw, 10); return Number.isSafeInteger(raw) && raw >= 0 ? raw : null;
return Number.isFinite(n) ? n : null; }
if (typeof raw === "string" && /^[0-9]+$/.test(raw)) {
const count = Number(raw);
return Number.isSafeInteger(count) ? count : null;
}
return null;
} }
// True only for a token the explorer reported as having fewer holders than // True only for a token the explorer reported as having fewer holders than
+4 -3
View File
@@ -137,9 +137,10 @@ function parseTokenTransfer(tt, addrLower, chainId) {
contractAddress: normalizeAddress( contractAddress: normalizeAddress(
tt.token?.address_hash || tt.token?.address || "", tt.token?.address_hash || tt.token?.address || "",
), ),
// null when the explorer reported no count: unknown, not zero. The // null when the explorer reported no readable count: unknown, not
// low-holder filter declines to judge a null, so a legitimate token // zero. The low-holder filter declines to judge a null, so a
// is not hidden because a field went missing upstream. // legitimate token is not hidden because a field went missing
// upstream.
holders: parseHoldersCount(tt.token?.holders_count), holders: parseHoldersCount(tt.token?.holders_count),
chainId: chainId, chainId: chainId,
}; };
+33
View File
@@ -57,6 +57,39 @@ describe("parseHoldersCount", () => {
expect(parseHoldersCount("many")).toBeNull(); expect(parseHoldersCount("many")).toBeNull();
expect(parseHoldersCount(NaN)).toBeNull(); expect(parseHoldersCount(NaN)).toBeNull();
}); });
// Each of these starts with a digit, so reading only the leading digits
// would turn it into a small reported count, and a small count is
// exactly what hides a token as spam (issue #251).
test.each(["1,000", "0x10", "1e3", "12 holders"])(
"%p is not read in part: it is unknown",
(raw) => {
expect(parseHoldersCount(raw)).toBeNull();
},
);
test("a negative count is unknown", () => {
expect(parseHoldersCount("-5")).toBeNull();
expect(parseHoldersCount(-5)).toBeNull();
});
// A number holds a whole number exactly only up to 2^53 - 1. Past that a
// string of digits would come back rounded, and a long enough one as
// Infinity, which would pass every holder-count floor.
test("a count too large for a number to hold exactly is unknown", () => {
expect(parseHoldersCount("9007199254740993")).toBeNull();
expect(parseHoldersCount("9".repeat(400))).toBeNull();
expect(parseHoldersCount(2 ** 53)).toBeNull();
});
test("the largest count a number holds exactly still parses", () => {
expect(parseHoldersCount("9007199254740991")).toBe(
Number.MAX_SAFE_INTEGER,
);
expect(parseHoldersCount(Number.MAX_SAFE_INTEGER)).toBe(
Number.MAX_SAFE_INTEGER,
);
});
}); });
describe("isLowHolderCount", () => { describe("isLowHolderCount", () => {