Filter spam tokens from balance display
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.
Cette révision appartient à :
2026-02-27 13:02:05 +07:00
Parent b64f9b56cc
révision 5af8a7873d
3 fichiers modifiés avec 58 ajouts et 18 suppressions
+6 -1
Voir le fichier
@@ -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();
} }
+6 -1
Voir le fichier
@@ -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();
+46 -16
Voir le fichier
@@ -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",
), );
}
}),
); );
} }
} }