From 5af8a7873d753eb4aa52032ebe4fb596b81eed08 Mon Sep 17 00:00:00 2001 From: sneak Date: Fri, 27 Feb 2026 13:02:05 +0700 Subject: [PATCH] Filter spam tokens from balance display Token balances from Blockscout are now filtered before display. A token only appears if it meets at least one criterion: - In the known 511-token list (by contract address) - Explicitly tracked by the user (added via + Token) - Has >= 1,000 holders on-chain Also rejects tokens spoofing a known symbol from a different contract address (same check used for transaction filtering). This prevents airdropped spam tokens like "OpenClaw" from appearing in the wallet without the user ever tracking them. --- src/background/index.js | 7 ++++- src/popup/index.js | 7 ++++- src/shared/balances.js | 62 ++++++++++++++++++++++++++++++----------- 3 files changed, 58 insertions(+), 18 deletions(-) diff --git a/src/background/index.js b/src/background/index.js index 861bf5d..16f97e6 100644 --- a/src/background/index.js +++ b/src/background/index.js @@ -497,7 +497,12 @@ async function backgroundRefresh() { if (now - (state.lastBalanceRefresh || 0) < BACKGROUND_REFRESH_INTERVAL) return; if (state.wallets.length === 0) return; - await refreshBalances(state.wallets, state.rpcUrl, state.blockscoutUrl); + await refreshBalances( + state.wallets, + state.rpcUrl, + state.blockscoutUrl, + state.trackedTokens, + ); state.lastBalanceRefresh = now; await saveState(); } diff --git a/src/popup/index.js b/src/popup/index.js index af43b14..33455b4 100644 --- a/src/popup/index.js +++ b/src/popup/index.js @@ -34,7 +34,12 @@ async function doRefreshAndRender() { try { await Promise.all([ refreshPrices(), - refreshBalances(state.wallets, state.rpcUrl, state.blockscoutUrl), + refreshBalances( + state.wallets, + state.rpcUrl, + state.blockscoutUrl, + state.trackedTokens, + ), ]); state.lastBalanceRefresh = Date.now(); await saveState(); diff --git a/src/shared/balances.js b/src/shared/balances.js index 1dfe9a9..24f19a6 100644 --- a/src/shared/balances.js +++ b/src/shared/balances.js @@ -11,6 +11,7 @@ const { const { ERC20_ABI } = require("./constants"); const { log, debugFetch } = require("./log"); const { deriveAddressFromXpub } = require("./wallet"); +const { KNOWN_SYMBOLS, TOKEN_BY_ADDRESS } = require("./tokenList"); // Use a static network to skip auto-detection (which can fail and cause // "could not coalesce error" on some RPC endpoints like Cloudflare). @@ -38,7 +39,9 @@ function formatTokenBalance(raw, decimals) { // Fetch token balances for a single address from Blockscout. // Returns [{ address, symbol, decimals, balance }]. -async function fetchTokenBalances(address, blockscoutUrl) { +// Filters out spam: only shows tokens that are in the known token list, +// explicitly tracked by the user, or have >= 1000 holders. +async function fetchTokenBalances(address, blockscoutUrl, trackedTokens) { try { const resp = await debugFetch( blockscoutUrl + "/addresses/" + address + "/token-balances", @@ -49,18 +52,43 @@ async function fetchTokenBalances(address, blockscoutUrl) { } const items = await resp.json(); if (!Array.isArray(items)) return null; + + const trackedSet = new Set( + (trackedTokens || []).map((t) => t.address.toLowerCase()), + ); + const balances = []; for (const item of items) { if (item.token?.type !== "ERC-20") continue; const decimals = parseInt(item.token.decimals || "18", 10); const bal = formatTokenBalance(item.value || "0", decimals); if (bal === "0.0") continue; + + const tokenAddr = (item.token.address_hash || "").toLowerCase(); + const holders = parseInt(item.token.holders_count || "0", 10); + const isKnown = TOKEN_BY_ADDRESS.has(tokenAddr); + const isTracked = trackedSet.has(tokenAddr); + const hasEnoughHolders = holders >= 1000; + + // Skip spam tokens the user never asked to see + if (!isKnown && !isTracked && !hasEnoughHolders) continue; + + // Skip tokens spoofing a known symbol from a different address + const sym = (item.token.symbol || "").toUpperCase(); + const legitAddr = KNOWN_SYMBOLS.get(sym); + if ( + legitAddr !== undefined && + legitAddr !== null && + tokenAddr !== legitAddr + ) + continue; + balances.push({ address: item.token.address_hash, symbol: item.token.symbol || "???", decimals: decimals, balance: bal, - holders: parseInt(item.token.holders_count || "0", 10), + holders: holders, }); } return balances; @@ -71,7 +99,7 @@ async function fetchTokenBalances(address, blockscoutUrl) { } // Fetch ETH balances, ENS names, and ERC-20 token balances for all addresses. -async function refreshBalances(wallets, rpcUrl, blockscoutUrl) { +async function refreshBalances(wallets, rpcUrl, blockscoutUrl, trackedTokens) { log.debugf("refreshBalances start, rpc:", rpcUrl); const provider = getProvider(rpcUrl); const updates = []; @@ -109,19 +137,21 @@ async function refreshBalances(wallets, rpcUrl, blockscoutUrl) { // ERC-20 token balances via Blockscout updates.push( - fetchTokenBalances(addr.address, blockscoutUrl).then( - (balances) => { - if (balances !== null) { - addr.tokenBalances = balances; - log.debugf( - "Token balances", - addr.address, - balances.length, - "tokens", - ); - } - }, - ), + fetchTokenBalances( + addr.address, + blockscoutUrl, + trackedTokens, + ).then((balances) => { + if (balances !== null) { + addr.tokenBalances = balances; + log.debugf( + "Token balances", + addr.address, + balances.length, + "tokens", + ); + } + }), ); } }