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
förälder b64f9b56cc
incheckning 5af8a7873d
3 ändrade filer med 58 tillägg och 18 borttagningar

Visa fil

@@ -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();
}

Visa fil

@@ -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();

Visa fil

@@ -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",
);
}
}),
);
}
}