From 7ababcf69e3715cc978ab5aa559c390e1241308d Mon Sep 17 00:00:00 2001 From: clawbot <35+clawbot@noreply.example.org> Date: Mon, 2 Nov 2026 06:04:07 +0100 Subject: [PATCH] fix: the balance list and the add-token screens apply the spoof rule to tracked tokens (closes #705) 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 --- README.md | 34 ++++++----- docs/README.md | 6 +- src/popup/views/addToken.js | 10 ++++ src/popup/views/helpers.js | 4 ++ src/popup/views/settingsAddToken.js | 11 ++++ tests/spoofedTrackedToken.test.js | 91 +++++++++++++++++++++++++++++ 6 files changed, 137 insertions(+), 19 deletions(-) create mode 100644 tests/spoofedTrackedToken.test.js diff --git a/README.md b/README.md index a524852..2036487 100644 --- a/README.md +++ b/README.md @@ -1473,21 +1473,20 @@ tracked. 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 -more holders. A token claiming a symbol from the bundled list from any other -contract address is always dropped, tracked or not, and so is any token claiming -a symbol that belongs to the native asset and therefore has no legitimate -contract at all (`"ETH"`, and every network's `nativeCurrency`, such as -`"SepoliaETH"`, on every network). That filter is unconditional — the "Hide -tokens with fewer than 1,000 holders" setting governs the transaction history -and the send-screen token selector, not this list. A token's holder count is -unknown when the explorer reports none, or reports anything other than a whole -number written in digits alone, such as `1,000` or `1e3` (`parseHoldersCount()` -in `src/shared/holders.js`). This list does not take an unknown count as 1,000 -or more, so such a token is shown only when it is on the bundled list or -tracked. Every nonzero holding of a token shown is stored exactly, 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 +more holders. The balance list always drops a token that claims a bundled-list +symbol from another contract address, whatever the settings. The list also drops +every token that claims the native asset's symbol: `"ETH"`, or any network's +`nativeCurrency`, such as `"SepoliaETH"`. That filter is unconditional — the +"Hide tokens with fewer than 1,000 holders" setting governs the transaction +history and the send-screen token selector, not this list. A token's holder +count is unknown when the explorer reports none, or reports anything other than +a whole number written in digits alone, such as `1,000` or `1e3` +(`parseHoldersCount()` in `src/shared/holders.js`). This list does not take an +unknown count as 1,000 or more, so such a token is shown only when it is on the +bundled list or tracked. Every nonzero holding of a token shown is stored +exactly, 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. @@ -2239,6 +2238,8 @@ view would leave a wallet one click from deletion. **AddressDetail** - "Add" with a token already tracked, a scam-listed address, or a failed 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 after AddToken was opened again → tracks the token, no screen change - "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) - A duplicate, a scam-listed address, or a failed contract lookup → flash 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 lookup answers → nothing is tracked; "The active network changed before the lookup finished, so the token was not added." flash message, no screen diff --git a/docs/README.md b/docs/README.md index 9d8465f..865c3eb 100644 --- a/docs/README.md +++ b/docs/README.md @@ -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 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 -fills it. The send token list always applies the check. Your balances apply it -too, with one exception: a token claiming the symbol "ETH" is not filtered -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. +fills it. The send token list always applies the check. Your balance list always +applies the check too. **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 diff --git a/src/popup/views/addToken.js b/src/popup/views/addToken.js index 36a34e7..d299a48 100644 --- a/src/popup/views/addToken.js +++ b/src/popup/views/addToken.js @@ -3,6 +3,7 @@ const { getTopTokens } = require("../../shared/tokenList"); const { state, saveState, currentNetwork } = require("../../shared/state"); const { lookupTokenInfo } = require("../../shared/balances"); const { isScamAddress } = require("../../shared/scamlist"); +const { isSpoofedSymbol } = require("../../shared/symbolSpoof"); const { log } = require("../../shared/log"); // Counts the times AddToken has opened. A contract lookup started before @@ -77,6 +78,15 @@ function init(ctx) { ); 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); state.trackedTokens.push({ address: contractAddr, diff --git a/src/popup/views/helpers.js b/src/popup/views/helpers.js index fc56238..261fb8e 100644 --- a/src/popup/views/helpers.js +++ b/src/popup/views/helpers.js @@ -33,6 +33,7 @@ const { networkByChainId } = require("../../shared/networks"); const { formatUsd, getPrice } = require("../../shared/prices"); const { state, saveState, currentNetwork } = require("../../shared/state"); const { displaySymbol } = require("../../shared/symbolDisplay"); +const { isSpoofedSymbol } = require("../../shared/symbolSpoof"); const { walletHasRecoveryPhrase } = require("../../shared/wallet"); const { markViewRendered } = require("../viewRouter"); @@ -447,6 +448,9 @@ function balanceLinesForAddress(addr, trackedTokens, showZero) { if (showZero && trackedTokens) { for (const t of trackedTokens) { 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( t.symbol, 0, diff --git a/src/popup/views/settingsAddToken.js b/src/popup/views/settingsAddToken.js index a420fe0..38ed93f 100644 --- a/src/popup/views/settingsAddToken.js +++ b/src/popup/views/settingsAddToken.js @@ -3,6 +3,7 @@ const { getTopTokens } = require("../../shared/tokenList"); const { state, saveState, currentNetwork } = require("../../shared/state"); const { lookupTokenInfo } = require("../../shared/balances"); const { isScamAddress } = require("../../shared/scamlist"); +const { isSpoofedSymbol } = require("../../shared/symbolSpoof"); const { log } = require("../../shared/log"); let ctx; @@ -155,6 +156,16 @@ function init(_ctx) { ); 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); state.trackedTokens.push({ address: addr, diff --git a/tests/spoofedTrackedToken.test.js b/tests/spoofedTrackedToken.test.js new file mode 100644 index 0000000..c7de3af --- /dev/null +++ b/tests/spoofedTrackedToken.test.js @@ -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([]); + }); +});