From 5f853322b06b33e1fb9b2c023d6be816d709d06b Mon Sep 17 00:00:00 2001 From: Jeff Emmett Date: Wed, 25 Mar 2026 16:08:52 -0700 Subject: [PATCH] fix(rwallet): filter spam tokens via CoinGecko verification ERC-20 tokens not recognized by CoinGecko and valued < $1 by Safe API are now stripped from balance responses, removing fake ETH and airdrop spam. Co-Authored-By: Claude Opus 4.6 --- modules/rwallet/lib/price-feed.ts | 20 ++++++++++++++++++-- modules/rwallet/mod.ts | 8 ++++---- 2 files changed, 22 insertions(+), 6 deletions(-) diff --git a/modules/rwallet/lib/price-feed.ts b/modules/rwallet/lib/price-feed.ts index ee3f598..3f5ead9 100644 --- a/modules/rwallet/lib/price-feed.ts +++ b/modules/rwallet/lib/price-feed.ts @@ -146,8 +146,9 @@ interface BalanceItem { export async function enrichWithPrices( balances: BalanceItem[], chainId: string, + options?: { filterSpam?: boolean }, ): Promise { - // Skip testnets and unsupported chains + // Skip testnets and unsupported chains — no CoinGecko data to verify against if (!CHAIN_PLATFORM[chainId] && !NATIVE_COIN_ID[chainId]) return balances; // Check if any balance actually needs pricing @@ -165,7 +166,7 @@ export async function enrichWithPrices( try { const priceData = await fetchChainPrices(chainId, tokenAddresses); - return balances.map((b) => { + const enriched = balances.map((b) => { // Skip if already has a real fiat value if (b.fiatBalance && b.fiatBalance !== "0" && parseFloat(b.fiatBalance) > 0) { return b; @@ -194,6 +195,21 @@ export async function enrichWithPrices( fiatBalance: String(fiatValue), }; }); + + if (options?.filterSpam) { + return enriched.filter((b) => { + // Native tokens always pass + if (!b.tokenAddress || b.tokenAddress === "0x0000000000000000000000000000000000000000") return true; + // CoinGecko recognized this token + if (priceData.prices.has(b.tokenAddress.toLowerCase())) return true; + // Safe API independently valued it at >= $1 + if (parseFloat(b.fiatBalance || "0") >= 1) return true; + // Unknown ERC-20 with no verified value = spam + return false; + }); + } + + return enriched; } catch (e) { console.warn(`[price-feed] Failed to enrich prices for chain ${chainId}:`, e); return balances; diff --git a/modules/rwallet/mod.ts b/modules/rwallet/mod.ts index 8dd9fd9..d0b498c 100644 --- a/modules/rwallet/mod.ts +++ b/modules/rwallet/mod.ts @@ -47,7 +47,7 @@ routes.get("/api/safe/:chainId/:address/balances", async (c) => { fiatBalance: item.fiatBalance || "0", fiatConversion: item.fiatConversion || "0", })); - const enriched = (await enrichWithPrices(data, chainId)) + const enriched = (await enrichWithPrices(data, chainId, { filterSpam: true })) .filter(b => BigInt(b.balance || "0") > 0n); c.header("Cache-Control", "public, max-age=30"); return c.json(enriched); @@ -600,7 +600,7 @@ routes.get("/api/eoa/:chainId/:address/balances", async (c) => { await Promise.allSettled(promises); - const enriched = await enrichWithPrices(balances, chainId); + const enriched = await enrichWithPrices(balances, chainId, { filterSpam: true }); c.header("Cache-Control", "public, max-age=30"); return c.json(enriched); }); @@ -661,7 +661,7 @@ routes.get("/api/eoa/:address/all-balances", async (c) => { await Promise.allSettled(tokenPromises); if (chainBalances.length > 0) { - const enriched = await enrichWithPrices(chainBalances, chainId); + const enriched = await enrichWithPrices(chainBalances, chainId, { filterSpam: true }); results.push({ chainId, chainName: info.name, balances: enriched }); } }) @@ -700,7 +700,7 @@ routes.get("/api/safe/:address/all-balances", async (c) => { })).filter((b: BalanceItem) => BigInt(b.balance || "0") > 0n); if (chainBalances.length > 0) { - const enriched = await enrichWithPrices(chainBalances, chainId); + const enriched = await enrichWithPrices(chainBalances, chainId, { filterSpam: true }); results.push({ chainId, chainName: info.name, balances: enriched }); } } catch {}