Filter spam tokens from balance display
All checks were successful
check / check (push) Successful in 5s

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.
This commit is contained in:
2026-02-27 13:02:05 +07:00
parent b64f9b56cc
commit 5af8a7873d
3 changed files with 58 additions and 18 deletions

View File

@@ -497,7 +497,12 @@ async function backgroundRefresh() {
if (now - (state.lastBalanceRefresh || 0) < BACKGROUND_REFRESH_INTERVAL) if (now - (state.lastBalanceRefresh || 0) < BACKGROUND_REFRESH_INTERVAL)
return; return;
if (state.wallets.length === 0) 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; state.lastBalanceRefresh = now;
await saveState(); await saveState();
} }

View File

@@ -34,7 +34,12 @@ async function doRefreshAndRender() {
try { try {
await Promise.all([ await Promise.all([
refreshPrices(), refreshPrices(),
refreshBalances(state.wallets, state.rpcUrl, state.blockscoutUrl), refreshBalances(
state.wallets,
state.rpcUrl,
state.blockscoutUrl,
state.trackedTokens,
),
]); ]);
state.lastBalanceRefresh = Date.now(); state.lastBalanceRefresh = Date.now();
await saveState(); await saveState();

View File

@@ -11,6 +11,7 @@ const {
const { ERC20_ABI } = require("./constants"); const { ERC20_ABI } = require("./constants");
const { log, debugFetch } = require("./log"); const { log, debugFetch } = require("./log");
const { deriveAddressFromXpub } = require("./wallet"); 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 // Use a static network to skip auto-detection (which can fail and cause
// "could not coalesce error" on some RPC endpoints like Cloudflare). // "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. // Fetch token balances for a single address from Blockscout.
// Returns [{ address, symbol, decimals, balance }]. // 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 { try {
const resp = await debugFetch( const resp = await debugFetch(
blockscoutUrl + "/addresses/" + address + "/token-balances", blockscoutUrl + "/addresses/" + address + "/token-balances",
@@ -49,18 +52,43 @@ async function fetchTokenBalances(address, blockscoutUrl) {
} }
const items = await resp.json(); const items = await resp.json();
if (!Array.isArray(items)) return null; if (!Array.isArray(items)) return null;
const trackedSet = new Set(
(trackedTokens || []).map((t) => t.address.toLowerCase()),
);
const balances = []; const balances = [];
for (const item of items) { for (const item of items) {
if (item.token?.type !== "ERC-20") continue; if (item.token?.type !== "ERC-20") continue;
const decimals = parseInt(item.token.decimals || "18", 10); const decimals = parseInt(item.token.decimals || "18", 10);
const bal = formatTokenBalance(item.value || "0", decimals); const bal = formatTokenBalance(item.value || "0", decimals);
if (bal === "0.0") continue; 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({ balances.push({
address: item.token.address_hash, address: item.token.address_hash,
symbol: item.token.symbol || "???", symbol: item.token.symbol || "???",
decimals: decimals, decimals: decimals,
balance: bal, balance: bal,
holders: parseInt(item.token.holders_count || "0", 10), holders: holders,
}); });
} }
return balances; return balances;
@@ -71,7 +99,7 @@ async function fetchTokenBalances(address, blockscoutUrl) {
} }
// Fetch ETH balances, ENS names, and ERC-20 token balances for all addresses. // 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); log.debugf("refreshBalances start, rpc:", rpcUrl);
const provider = getProvider(rpcUrl); const provider = getProvider(rpcUrl);
const updates = []; const updates = [];
@@ -109,19 +137,21 @@ async function refreshBalances(wallets, rpcUrl, blockscoutUrl) {
// ERC-20 token balances via Blockscout // ERC-20 token balances via Blockscout
updates.push( updates.push(
fetchTokenBalances(addr.address, blockscoutUrl).then( fetchTokenBalances(
(balances) => { addr.address,
if (balances !== null) { blockscoutUrl,
addr.tokenBalances = balances; trackedTokens,
log.debugf( ).then((balances) => {
"Token balances", if (balances !== null) {
addr.address, addr.tokenBalances = balances;
balances.length, log.debugf(
"tokens", "Token balances",
); addr.address,
} balances.length,
}, "tokens",
), );
}
}),
); );
} }
} }