From b5b4f7596871d3d9027e442e54e140b970689182 Mon Sep 17 00:00:00 2001 From: sneak Date: Thu, 26 Feb 2026 15:22:11 +0700 Subject: [PATCH] Add anti-poisoning filters for token transfers and send view Three layers of defense against address poisoning attacks: 1. Known symbol verification: tokens claiming a symbol from the hardcoded top-250 list (e.g. "ETH", "USDT") but from an unrecognized contract are identified as spoofs and always hidden. Their contract addresses are auto-added to the fraud blocklist. 2. Low-holder filtering: tokens with <1000 holders are hidden from both transaction history and the send token selector. Controlled by the "Hide tokens with fewer than 1,000 holders" setting. 3. Fraud contract blocklist: a persistent local list of detected fraud contract addresses. Transactions involving these contracts are hidden. Controlled by the "Hide transactions from detected fraud contracts" setting. Both settings default to on and can be disabled in Settings. Fetching and filtering are separated: fetchRecentTransactions returns raw data, filterTransactions is a pure function applying heuristics. Token holder counts are now passed through from the Blockscout API. --- src/popup/index.html | 26 ++++++++++++ src/popup/views/addressDetail.js | 29 +++++++++++-- src/popup/views/send.js | 15 +++++++ src/popup/views/settings.js | 12 ++++++ src/shared/balances.js | 1 + src/shared/state.js | 18 ++++++++ src/shared/tokens.js | 20 ++++++++- src/shared/transactions.js | 73 +++++++++++++++++++++++++++++++- 8 files changed, 188 insertions(+), 6 deletions(-) diff --git a/src/popup/index.html b/src/popup/index.html index 9fe97b2..4211288 100644 --- a/src/popup/index.html +++ b/src/popup/index.html @@ -541,6 +541,32 @@ +
+

Token Spam Protection

+

+ Scammers deploy fake token contracts that emit spoofed + Transfer events to plant look-alike addresses in your + transaction history. These filters hide fraudulent + transfers and prevent interaction with suspicious + tokens. +

+ + +
+

Allowed Sites

diff --git a/src/popup/views/addressDetail.js b/src/popup/views/addressDetail.js index 6eacb49..571c69b 100644 --- a/src/popup/views/addressDetail.js +++ b/src/popup/views/addressDetail.js @@ -8,9 +8,12 @@ const { formatAddressHtml, truncateMiddle, } = require("./helpers"); -const { state, currentAddress } = require("../../shared/state"); +const { state, currentAddress, saveState } = require("../../shared/state"); const { formatUsd, getAddressValueUsd } = require("../../shared/prices"); -const { fetchRecentTransactions } = require("../../shared/transactions"); +const { + fetchRecentTransactions, + filterTransactions, +} = require("../../shared/transactions"); const { resolveEnsNames } = require("../../shared/ens"); const { updateSendBalance, renderSendTokenSelect } = require("./send"); const { log } = require("../../shared/log"); @@ -84,7 +87,27 @@ let ensNameMap = new Map(); async function loadTransactions(address) { try { - const txs = await fetchRecentTransactions(address, state.blockscoutUrl); + const rawTxs = await fetchRecentTransactions( + address, + state.blockscoutUrl, + ); + const result = filterTransactions(rawTxs, { + hideLowHolderTokens: state.hideLowHolderTokens, + hideFraudContracts: state.hideFraudContracts, + fraudContracts: state.fraudContracts, + }); + const txs = result.transactions; + + // Persist any newly discovered fraud contracts + if (result.newFraudContracts.length > 0) { + for (const addr of result.newFraudContracts) { + if (!state.fraudContracts.includes(addr)) { + state.fraudContracts.push(addr); + } + } + await saveState(); + } + loadedTxs = txs; // Collect unique counterparty addresses for ENS resolution. diff --git a/src/popup/views/send.js b/src/popup/views/send.js index 98c256c..e148a64 100644 --- a/src/popup/views/send.js +++ b/src/popup/views/send.js @@ -3,11 +3,26 @@ const { $, showFlash, formatAddressHtml } = require("./helpers"); const { state, currentAddress } = require("../../shared/state"); const { getProvider } = require("../../shared/balances"); +const { KNOWN_SYMBOLS } = require("../../shared/tokens"); + +function isSpoofedToken(t) { + const upper = (t.symbol || "").toUpperCase(); + if (!KNOWN_SYMBOLS.has(upper)) return false; + const legit = KNOWN_SYMBOLS.get(upper); + if (legit === null) return true; + return t.address.toLowerCase() !== legit; +} function renderSendTokenSelect(addr) { const sel = $("send-token"); sel.innerHTML = ''; + const fraudSet = new Set( + (state.fraudContracts || []).map((a) => a.toLowerCase()), + ); for (const t of addr.tokenBalances || []) { + if (isSpoofedToken(t)) continue; + if (fraudSet.has(t.address.toLowerCase())) continue; + if (state.hideLowHolderTokens && (t.holders || 0) < 1000) continue; const opt = document.createElement("option"); opt.value = t.address; opt.textContent = t.symbol; diff --git a/src/popup/views/settings.js b/src/popup/views/settings.js index 11fcb45..5cc23df 100644 --- a/src/popup/views/settings.js +++ b/src/popup/views/settings.js @@ -113,6 +113,18 @@ function init(ctx) { showFlash("Saved."); }); + $("settings-hide-low-holders").checked = state.hideLowHolderTokens; + $("settings-hide-low-holders").addEventListener("change", async () => { + state.hideLowHolderTokens = $("settings-hide-low-holders").checked; + await saveState(); + }); + + $("settings-hide-fraud-contracts").checked = state.hideFraudContracts; + $("settings-hide-fraud-contracts").addEventListener("change", async () => { + state.hideFraudContracts = $("settings-hide-fraud-contracts").checked; + await saveState(); + }); + $("btn-main-add-wallet").addEventListener("click", ctx.showAddWalletView); $("btn-settings-back").addEventListener("click", () => { diff --git a/src/shared/balances.js b/src/shared/balances.js index f51d4e3..363432d 100644 --- a/src/shared/balances.js +++ b/src/shared/balances.js @@ -60,6 +60,7 @@ async function fetchTokenBalances(address, blockscoutUrl) { symbol: item.token.symbol || "???", decimals: decimals, balance: bal, + holders: parseInt(item.token.holders_count || "0", 10), }); } return balances; diff --git a/src/shared/state.js b/src/shared/state.js index c3f6e4a..eb7e65d 100644 --- a/src/shared/state.js +++ b/src/shared/state.js @@ -18,6 +18,10 @@ const DEFAULT_STATE = { allowedSites: {}, deniedSites: {}, rememberSiteChoice: true, + hideLowHolderTokens: true, + hideFraudContracts: true, + fraudContracts: [], + tokenHolderCache: {}, }; const state = { @@ -38,6 +42,10 @@ async function saveState() { allowedSites: state.allowedSites, deniedSites: state.deniedSites, rememberSiteChoice: state.rememberSiteChoice, + hideLowHolderTokens: state.hideLowHolderTokens, + hideFraudContracts: state.hideFraudContracts, + fraudContracts: state.fraudContracts, + tokenHolderCache: state.tokenHolderCache, }; await storageApi.set({ autistmask: persisted }); } @@ -66,6 +74,16 @@ async function loadState() { saved.rememberSiteChoice !== undefined ? saved.rememberSiteChoice : true; + state.hideLowHolderTokens = + saved.hideLowHolderTokens !== undefined + ? saved.hideLowHolderTokens + : true; + state.hideFraudContracts = + saved.hideFraudContracts !== undefined + ? saved.hideFraudContracts + : true; + state.fraudContracts = saved.fraudContracts || []; + state.tokenHolderCache = saved.tokenHolderCache || {}; } } diff --git a/src/shared/tokens.js b/src/shared/tokens.js index 76e6b27..5b90119 100644 --- a/src/shared/tokens.js +++ b/src/shared/tokens.js @@ -784,6 +784,19 @@ const TOKENS = DEFAULT_TOKENS.filter((t) => { return true; }); +// Map of known token symbols (uppercased) to their legitimate contract +// addresses (lowercased). "ETH" maps to null since it's the native token. +// Used to detect symbol-spoofing attacks: if a token transfer claims a +// symbol from this map but its contract address doesn't match, it's fake. +const KNOWN_SYMBOLS = new Map(); +KNOWN_SYMBOLS.set("ETH", null); +for (const t of TOKENS) { + const upper = t.symbol.toUpperCase(); + if (!KNOWN_SYMBOLS.has(upper)) { + KNOWN_SYMBOLS.set(upper, t.address.toLowerCase()); + } +} + // Return the symbols of the top N tokens (by position in the list). function getTopTokenSymbols(n) { return TOKENS.slice(0, n).map((t) => t.symbol); @@ -817,4 +830,9 @@ async function getTopTokenPrices(n) { return prices; } -module.exports = { TOKENS, getTopTokenSymbols, getTopTokenPrices }; +module.exports = { + TOKENS, + KNOWN_SYMBOLS, + getTopTokenSymbols, + getTopTokenPrices, +}; diff --git a/src/shared/transactions.js b/src/shared/transactions.js index 13ab540..9cca8dc 100644 --- a/src/shared/transactions.js +++ b/src/shared/transactions.js @@ -1,9 +1,14 @@ // Transaction history fetching via Blockscout v2 API. // Fetches normal transactions and ERC-20 token transfers, // merges them, and returns the most recent entries. +// +// Filtering is separated from fetching: fetchRecentTransactions returns +// raw parsed data including token metadata, and filterTransactions is +// a pure function that applies anti-poisoning heuristics. const { formatEther, formatUnits } = require("ethers"); const { log } = require("./log"); +const { KNOWN_SYMBOLS } = require("./tokens"); function formatTxValue(val) { const parts = val.split("."); @@ -25,6 +30,8 @@ function parseTx(tx, addrLower) { symbol: "ETH", direction: from.toLowerCase() === addrLower ? "sent" : "received", isError: tx.status !== "ok", + contractAddress: null, + holders: null, }; } @@ -43,6 +50,12 @@ function parseTokenTransfer(tt, addrLower) { symbol: tt.token?.symbol || "?", direction: from.toLowerCase() === addrLower ? "sent" : "received", isError: false, + contractAddress: ( + tt.token?.address_hash || + tt.token?.address || + "" + ).toLowerCase(), + holders: parseInt(tt.token?.holders_count || "0", 10), }; } @@ -84,7 +97,7 @@ async function fetchRecentTransactions(address, blockscoutUrl, count = 25) { txs.push(parseTx(tx, addrLower)); } - // Deduplicate: skip token transfers whose tx hash is already in the list + // Deduplicate: skip token transfers whose tx hash is already present const seenHashes = new Set(txs.map((t) => t.hash)); for (const tt of ttJson.items || []) { if (seenHashes.has(tt.transaction_hash)) continue; @@ -97,4 +110,60 @@ async function fetchRecentTransactions(address, blockscoutUrl, count = 25) { return result; } -module.exports = { fetchRecentTransactions }; +// Check if a token transfer is spoofing a known symbol. +// Returns true if the symbol matches a known token but the contract +// address doesn't match the legitimate one. +function isSpoofedSymbol(tx) { + if (!tx.contractAddress) return false; + const symbol = (tx.symbol || "").toUpperCase(); + if (!KNOWN_SYMBOLS.has(symbol)) return false; + const legit = KNOWN_SYMBOLS.get(symbol); + if (legit === null) return true; // "ETH" as ERC-20 is always fake + return tx.contractAddress !== legit; +} + +// Pure filter function. Takes raw transactions and filter settings, +// returns { transactions, newFraudContracts }. +function filterTransactions(txs, filters = {}) { + const fraudSet = new Set( + (filters.fraudContracts || []).map((a) => a.toLowerCase()), + ); + const newFraud = []; + const filtered = []; + + for (const tx of txs) { + // Always filter spoofed known symbols and record the fraud contract + if (isSpoofedSymbol(tx)) { + if (tx.contractAddress && !fraudSet.has(tx.contractAddress)) { + fraudSet.add(tx.contractAddress); + newFraud.push(tx.contractAddress); + } + continue; + } + + // Filter fraud contracts if setting is on + if ( + filters.hideFraudContracts && + tx.contractAddress && + fraudSet.has(tx.contractAddress) + ) { + continue; + } + + // Filter low-holder tokens (<1000) if setting is on + if ( + filters.hideLowHolderTokens && + tx.contractAddress && + tx.holders !== null && + tx.holders < 1000 + ) { + continue; + } + + filtered.push(tx); + } + + return { transactions: filtered, newFraudContracts: newFraud }; +} + +module.exports = { fetchRecentTransactions, filterTransactions };