fix: the balance list and the add-token screens apply the spoof rule to tracked tokens (closes #705)
check / check (push) Canceled after 0s
e2e / e2e-chrome (push) Canceled after 0s
e2e / e2e-firefox (push) Canceled after 0s

A tracked token that the spoof rule refuses still got a zero row in the balance list while "Show tracked tokens with zero balance" was on. balanceLinesForAddress() now asks isSpoofedSymbol() before it adds that row. AddToken and SettingsAddToken refuse such a token after the contract lookup, with the flash "Not added: this token fakes a known symbol." The bundled-list buttons on SettingsAddToken need no check: the rule never refuses a bundled token. README.md and docs/README.md now say the balance list drops such a token whatever the settings.

Model: opus-5-5
This commit was merged in pull request #716.
This commit is contained in:
2026-11-02 06:04:07 +01:00
parent 3191577f73
commit 7ababcf69e
6 changed files with 137 additions and 19 deletions
+19 -15
View File
@@ -1473,21 +1473,20 @@ tracked.
A tracked token is shown when its balance is nonzero, and any other ERC-20 when A tracked token is shown when its balance is nonzero, and any other ERC-20 when
its balance is nonzero and it is in the bundled known-token list or has 1,000 or its balance is nonzero and it is in the bundled known-token list or has 1,000 or
more holders. A token claiming a symbol from the bundled list from any other more holders. The balance list always drops a token that claims a bundled-list
contract address is always dropped, tracked or not, and so is any token claiming symbol from another contract address, whatever the settings. The list also drops
a symbol that belongs to the native asset and therefore has no legitimate every token that claims the native asset's symbol: `"ETH"`, or any network's
contract at all (`"ETH"`, and every network's `nativeCurrency`, such as `nativeCurrency`, such as `"SepoliaETH"`. That filter is unconditional — the
`"SepoliaETH"`, on every network). That filter is unconditional — the "Hide "Hide tokens with fewer than 1,000 holders" setting governs the transaction
tokens with fewer than 1,000 holders" setting governs the transaction history history and the send-screen token selector, not this list. A token's holder
and the send-screen token selector, not this list. A token's holder count is count is unknown when the explorer reports none, or reports anything other than
unknown when the explorer reports none, or reports anything other than a whole a whole number written in digits alone, such as `1,000` or `1e3`
number written in digits alone, such as `1,000` or `1e3` (`parseHoldersCount()` (`parseHoldersCount()` in `src/shared/holders.js`). This list does not take an
in `src/shared/holders.js`). This list does not take an unknown count as 1,000 unknown count as 1,000 or more, so such a token is shown only when it is on the
or more, so such a token is shown only when it is on the bundled list or bundled list or tracked. Every nonzero holding of a token shown is stored
tracked. Every nonzero holding of a token shown is stored exactly, however exactly, however small, but a holding below 0.000001 is left out of the balance
small, but a holding below 0.000001 is left out of the balance lists, the lists, the send-screen token selector, the address total and the remove-address
send-screen token selector, the address total and the remove-address warning warning (`isBelowOneMillionth()` in `src/shared/amountDisplay.js`). The Send and
(`isBelowOneMillionth()` in `src/shared/amountDisplay.js`). The Send and
confirmation screens show it when its token is the one being sent. Tracked 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 tokens with a zero balance are listed as well while "Show tracked tokens with
zero balance" is on. zero balance" is on.
@@ -2239,6 +2238,8 @@ view would leave a wallet one click from deletion.
**AddressDetail** **AddressDetail**
- "Add" with a token already tracked, a scam-listed address, or a failed - "Add" with a token already tracked, a scam-listed address, or a failed
contract lookup → flash message, no screen change contract lookup → flash message, no screen change
- "Add" with a token that the spoof rule refuses → nothing is tracked; "Not
added: this token fakes a known symbol." flash message, no screen change
- "Add", then the contract lookup answers while another screen shows, or - "Add", then the contract lookup answers while another screen shows, or
after AddToken was opened again → tracks the token, no screen change after AddToken was opened again → tracks the token, no screen change
- "Add", then a network switch in Settings before the contract lookup - "Add", then a network switch in Settings before the contract lookup
@@ -2547,6 +2548,9 @@ view would leave a wallet one click from deletion.
"Added SYMBOL" flash message (no screen change) "Added SYMBOL" flash message (no screen change)
- A duplicate, a scam-listed address, or a failed contract lookup → flash - A duplicate, a scam-listed address, or a failed contract lookup → flash
message, no screen change message, no screen change
- "Add" by contract address with a token that the spoof rule refuses →
nothing is tracked; "Not added: this token fakes a known symbol." flash
message, no screen change
- "Add" by contract address, then a network switch in Settings before the - "Add" by contract address, then a network switch in Settings before the
lookup answers → nothing is tracked; "The active network changed before lookup answers → nothing is tracked; "The active network changed before
the lookup finished, so the token was not added." flash message, no screen the lookup finished, so the token was not added." flash message, no screen
+2 -4
View File
@@ -380,10 +380,8 @@ but comes from an unrecognized contract, it is identified as a spoof and hidden.
In your transaction history this is the "Hide fake tokens impersonating a known In your 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 symbol" 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 added 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 fills it. The send token list always applies the check. Your balance list always
too, with one exception: a token claiming the symbol "ETH" is not filtered applies the check too.
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
+10
View File
@@ -3,6 +3,7 @@ const { getTopTokens } = require("../../shared/tokenList");
const { state, saveState, currentNetwork } = require("../../shared/state"); const { state, saveState, currentNetwork } = require("../../shared/state");
const { lookupTokenInfo } = require("../../shared/balances"); const { lookupTokenInfo } = require("../../shared/balances");
const { isScamAddress } = require("../../shared/scamlist"); const { isScamAddress } = require("../../shared/scamlist");
const { isSpoofedSymbol } = require("../../shared/symbolSpoof");
const { log } = require("../../shared/log"); const { log } = require("../../shared/log");
// Counts the times AddToken has opened. A contract lookup started before // Counts the times AddToken has opened. A contract lookup started before
@@ -77,6 +78,15 @@ function init(ctx) {
); );
return; return;
} }
// The balance list never shows a token that the spoof rule
// refuses, so such a token is not tracked.
if (isSpoofedSymbol(info.symbol, contractAddr)) {
showFlash("Not added: this token fakes a known symbol.");
if (addTokenScreenOpenings !== opening) return;
infoEl.textContent = "";
infoEl.style.visibility = "hidden";
return;
}
log.infof("Adding token", info.symbol, contractAddr); log.infof("Adding token", info.symbol, contractAddr);
state.trackedTokens.push({ state.trackedTokens.push({
address: contractAddr, address: contractAddr,
+4
View File
@@ -33,6 +33,7 @@ const { networkByChainId } = require("../../shared/networks");
const { formatUsd, getPrice } = require("../../shared/prices"); const { formatUsd, getPrice } = require("../../shared/prices");
const { state, saveState, currentNetwork } = require("../../shared/state"); const { state, saveState, currentNetwork } = require("../../shared/state");
const { displaySymbol } = require("../../shared/symbolDisplay"); const { displaySymbol } = require("../../shared/symbolDisplay");
const { isSpoofedSymbol } = require("../../shared/symbolSpoof");
const { walletHasRecoveryPhrase } = require("../../shared/wallet"); const { walletHasRecoveryPhrase } = require("../../shared/wallet");
const { markViewRendered } = require("../viewRouter"); const { markViewRendered } = require("../viewRouter");
@@ -447,6 +448,9 @@ function balanceLinesForAddress(addr, trackedTokens, showZero) {
if (showZero && trackedTokens) { if (showZero && trackedTokens) {
for (const t of trackedTokens) { for (const t of trackedTokens) {
if (seen.has(t.address.toLowerCase())) continue; if (seen.has(t.address.toLowerCase())) continue;
// A token that the spoof rule refuses has no stored balance, and
// gets no zero row either.
if (isSpoofedSymbol(t.symbol, t.address)) continue;
html += balanceLine( html += balanceLine(
t.symbol, t.symbol,
0, 0,
+11
View File
@@ -3,6 +3,7 @@ const { getTopTokens } = require("../../shared/tokenList");
const { state, saveState, currentNetwork } = require("../../shared/state"); const { state, saveState, currentNetwork } = require("../../shared/state");
const { lookupTokenInfo } = require("../../shared/balances"); const { lookupTokenInfo } = require("../../shared/balances");
const { isScamAddress } = require("../../shared/scamlist"); const { isScamAddress } = require("../../shared/scamlist");
const { isSpoofedSymbol } = require("../../shared/symbolSpoof");
const { log } = require("../../shared/log"); const { log } = require("../../shared/log");
let ctx; let ctx;
@@ -155,6 +156,16 @@ function init(_ctx) {
); );
return; return;
} }
// The balance list never shows a token that the spoof rule
// refuses, so such a token is not tracked. The rule never refuses
// a bundled token, so the "Top tokens:" buttons and the top 100
// dropdown need no such check.
if (isSpoofedSymbol(info.symbol, addr)) {
showFlash("Not added: this token fakes a known symbol.");
infoEl.textContent = "";
infoEl.style.visibility = "hidden";
return;
}
log.infof("Adding token", info.symbol, addr); log.infof("Adding token", info.symbol, addr);
state.trackedTokens.push({ state.trackedTokens.push({
address: addr, address: addr,
+91
View File
@@ -0,0 +1,91 @@
// A token that the spoof rule refuses never shows in the balance list, even
// tracked at zero balance with "Show tracked tokens with zero balance" on, and
// the two add-token screens refuse to track one
// (https://git.eeqj.de/sneak/AutistMask/issues/705). Driven through the real
// popup over a stored profile.
const {
bootPopup,
cleanupPopup,
unversionedValidProfile,
TOKEN_ADDRESS,
} = require("./support/popupBoot");
// Claims the symbol of the bundled USDT from another contract.
const FAKE_USDT = {
address: TOKEN_ADDRESS,
symbol: "USDT",
decimals: 6,
name: "Tether USD",
};
// A symbol the bundled list does not have, so the spoof rule passes it.
const TEST_TOKEN = {
address: "0xBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB",
symbol: "TST",
decimals: 18,
name: "Test Token",
};
const REFUSED_FLASH = "Not added: this token fakes a known symbol.";
afterEach(() => {
cleanupPopup();
});
test("a fake token tracked at zero balance is not listed while zero balances are shown", async () => {
const boot = await bootPopup(
unversionedValidProfile({
currentView: "address",
selectedWallet: 0,
selectedAddress: 0,
trackedTokens: [FAKE_USDT, TEST_TOKEN],
showZeroBalanceTokens: true,
}),
);
const rows = boot.node("address-balances").innerHTML;
expect(rows).toContain(`data-token="${TEST_TOKEN.address.toLowerCase()}"`);
expect(rows).not.toContain(
`data-token="${FAKE_USDT.address.toLowerCase()}"`,
);
});
// Each screen is opened from AddressDetail by the buttons named, in order.
describe.each([
[
"AddToken",
["btn-add-token"],
"add-token",
"add-token-address",
"btn-add-token-confirm",
],
[
"SettingsAddToken",
["btn-settings", "btn-settings-add-token"],
"settings-addtoken",
"settings-addtoken-address",
"btn-settings-addtoken-manual",
],
])("%s", (_, opening, view, field, button) => {
test("refuses a fake token by contract address", async () => {
const boot = await bootPopup(
unversionedValidProfile({
currentView: "address",
selectedWallet: 0,
selectedAddress: 0,
}),
);
for (const id of opening) await boot.click(id);
expect(boot.visibleViews()).toEqual([view]);
const { lookupTokenInfo } = require("../src/shared/balances");
lookupTokenInfo.mockResolvedValueOnce({
symbol: FAKE_USDT.symbol,
decimals: FAKE_USDT.decimals,
name: FAKE_USDT.name,
});
boot.node(field).value = FAKE_USDT.address;
await boot.click(button);
expect(boot.text("flash-msg")).toBe(REFUSED_FLASH);
expect(boot.visibleViews()).toEqual([view]);
expect(boot.storage.read("autistmask").trackedTokens).toEqual([]);
});
});