Add anti-poisoning filters for token transfers and send view
Some checks failed
check / check (push) Has been cancelled

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.
This commit is contained in:
2026-02-26 15:22:11 +07:00
parent d05de16e9c
commit b5b4f75968
8 changed files with 188 additions and 6 deletions

View File

@@ -541,6 +541,32 @@
</button> </button>
</div> </div>
<div class="bg-well p-3 mx-1 mb-3">
<h3 class="font-bold mb-1">Token Spam Protection</h3>
<p class="text-xs text-muted mb-2">
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.
</p>
<label
class="text-xs flex items-center gap-1 cursor-pointer mb-1"
>
<input type="checkbox" id="settings-hide-low-holders" />
Hide tokens with fewer than 1,000 holders
</label>
<label
class="text-xs flex items-center gap-1 cursor-pointer"
>
<input
type="checkbox"
id="settings-hide-fraud-contracts"
/>
Hide transactions from detected fraud contracts
</label>
</div>
<div class="bg-well p-3 mx-1 mb-3"> <div class="bg-well p-3 mx-1 mb-3">
<h3 class="font-bold mb-1">Allowed Sites</h3> <h3 class="font-bold mb-1">Allowed Sites</h3>
<p class="text-xs text-muted mb-2"> <p class="text-xs text-muted mb-2">

View File

@@ -8,9 +8,12 @@ const {
formatAddressHtml, formatAddressHtml,
truncateMiddle, truncateMiddle,
} = require("./helpers"); } = require("./helpers");
const { state, currentAddress } = require("../../shared/state"); const { state, currentAddress, saveState } = require("../../shared/state");
const { formatUsd, getAddressValueUsd } = require("../../shared/prices"); const { formatUsd, getAddressValueUsd } = require("../../shared/prices");
const { fetchRecentTransactions } = require("../../shared/transactions"); const {
fetchRecentTransactions,
filterTransactions,
} = require("../../shared/transactions");
const { resolveEnsNames } = require("../../shared/ens"); const { resolveEnsNames } = require("../../shared/ens");
const { updateSendBalance, renderSendTokenSelect } = require("./send"); const { updateSendBalance, renderSendTokenSelect } = require("./send");
const { log } = require("../../shared/log"); const { log } = require("../../shared/log");
@@ -84,7 +87,27 @@ let ensNameMap = new Map();
async function loadTransactions(address) { async function loadTransactions(address) {
try { 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; loadedTxs = txs;
// Collect unique counterparty addresses for ENS resolution. // Collect unique counterparty addresses for ENS resolution.

View File

@@ -3,11 +3,26 @@
const { $, showFlash, formatAddressHtml } = require("./helpers"); const { $, showFlash, formatAddressHtml } = require("./helpers");
const { state, currentAddress } = require("../../shared/state"); const { state, currentAddress } = require("../../shared/state");
const { getProvider } = require("../../shared/balances"); 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) { function renderSendTokenSelect(addr) {
const sel = $("send-token"); const sel = $("send-token");
sel.innerHTML = '<option value="ETH">ETH</option>'; sel.innerHTML = '<option value="ETH">ETH</option>';
const fraudSet = new Set(
(state.fraudContracts || []).map((a) => a.toLowerCase()),
);
for (const t of addr.tokenBalances || []) { 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"); const opt = document.createElement("option");
opt.value = t.address; opt.value = t.address;
opt.textContent = t.symbol; opt.textContent = t.symbol;

View File

@@ -113,6 +113,18 @@ function init(ctx) {
showFlash("Saved."); 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-main-add-wallet").addEventListener("click", ctx.showAddWalletView);
$("btn-settings-back").addEventListener("click", () => { $("btn-settings-back").addEventListener("click", () => {

View File

@@ -60,6 +60,7 @@ async function fetchTokenBalances(address, blockscoutUrl) {
symbol: item.token.symbol || "???", symbol: item.token.symbol || "???",
decimals: decimals, decimals: decimals,
balance: bal, balance: bal,
holders: parseInt(item.token.holders_count || "0", 10),
}); });
} }
return balances; return balances;

View File

@@ -18,6 +18,10 @@ const DEFAULT_STATE = {
allowedSites: {}, allowedSites: {},
deniedSites: {}, deniedSites: {},
rememberSiteChoice: true, rememberSiteChoice: true,
hideLowHolderTokens: true,
hideFraudContracts: true,
fraudContracts: [],
tokenHolderCache: {},
}; };
const state = { const state = {
@@ -38,6 +42,10 @@ async function saveState() {
allowedSites: state.allowedSites, allowedSites: state.allowedSites,
deniedSites: state.deniedSites, deniedSites: state.deniedSites,
rememberSiteChoice: state.rememberSiteChoice, rememberSiteChoice: state.rememberSiteChoice,
hideLowHolderTokens: state.hideLowHolderTokens,
hideFraudContracts: state.hideFraudContracts,
fraudContracts: state.fraudContracts,
tokenHolderCache: state.tokenHolderCache,
}; };
await storageApi.set({ autistmask: persisted }); await storageApi.set({ autistmask: persisted });
} }
@@ -66,6 +74,16 @@ async function loadState() {
saved.rememberSiteChoice !== undefined saved.rememberSiteChoice !== undefined
? saved.rememberSiteChoice ? saved.rememberSiteChoice
: true; : 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 || {};
} }
} }

View File

@@ -784,6 +784,19 @@ const TOKENS = DEFAULT_TOKENS.filter((t) => {
return true; 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). // Return the symbols of the top N tokens (by position in the list).
function getTopTokenSymbols(n) { function getTopTokenSymbols(n) {
return TOKENS.slice(0, n).map((t) => t.symbol); return TOKENS.slice(0, n).map((t) => t.symbol);
@@ -817,4 +830,9 @@ async function getTopTokenPrices(n) {
return prices; return prices;
} }
module.exports = { TOKENS, getTopTokenSymbols, getTopTokenPrices }; module.exports = {
TOKENS,
KNOWN_SYMBOLS,
getTopTokenSymbols,
getTopTokenPrices,
};

View File

@@ -1,9 +1,14 @@
// Transaction history fetching via Blockscout v2 API. // Transaction history fetching via Blockscout v2 API.
// Fetches normal transactions and ERC-20 token transfers, // Fetches normal transactions and ERC-20 token transfers,
// merges them, and returns the most recent entries. // 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 { formatEther, formatUnits } = require("ethers");
const { log } = require("./log"); const { log } = require("./log");
const { KNOWN_SYMBOLS } = require("./tokens");
function formatTxValue(val) { function formatTxValue(val) {
const parts = val.split("."); const parts = val.split(".");
@@ -25,6 +30,8 @@ function parseTx(tx, addrLower) {
symbol: "ETH", symbol: "ETH",
direction: from.toLowerCase() === addrLower ? "sent" : "received", direction: from.toLowerCase() === addrLower ? "sent" : "received",
isError: tx.status !== "ok", isError: tx.status !== "ok",
contractAddress: null,
holders: null,
}; };
} }
@@ -43,6 +50,12 @@ function parseTokenTransfer(tt, addrLower) {
symbol: tt.token?.symbol || "?", symbol: tt.token?.symbol || "?",
direction: from.toLowerCase() === addrLower ? "sent" : "received", direction: from.toLowerCase() === addrLower ? "sent" : "received",
isError: false, 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)); 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)); const seenHashes = new Set(txs.map((t) => t.hash));
for (const tt of ttJson.items || []) { for (const tt of ttJson.items || []) {
if (seenHashes.has(tt.transaction_hash)) continue; if (seenHashes.has(tt.transaction_hash)) continue;
@@ -97,4 +110,60 @@ async function fetchRecentTransactions(address, blockscoutUrl, count = 25) {
return result; 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 };