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 };