diff --git a/README.md b/README.md index a524852..9f19e58 100644 --- a/README.md +++ b/README.md @@ -1474,10 +1474,10 @@ 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 +contract address is always dropped, whatever the settings, 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 @@ -2239,6 +2239,9 @@ 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; "This + is a fake token impersonating a known symbol, so it was not added." 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 +2550,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; "This is a fake token impersonating a known symbol, so + it was not added." 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..d1ef7b0 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,18 @@ 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( + "This is a fake token impersonating a known symbol," + + " so it was not added.", + ); + 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..335edaa 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,19 @@ 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( + "This is a fake token impersonating a known symbol," + + " so it was not added.", + ); + 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..306ec67 --- /dev/null +++ b/tests/spoofedTrackedToken.test.js @@ -0,0 +1,92 @@ +// 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 = + "This is a fake token impersonating a known symbol, so it was not added."; + +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([]); + }); +});