/** * CoinGecko price feed with 5-minute in-memory cache. * Provides USD prices for native coins and ERC-20 tokens. */ // CoinGecko chain ID → platform ID mapping const CHAIN_PLATFORM: Record = { "1": "ethereum", "10": "optimistic-ethereum", "100": "xdai", "137": "polygon-pos", "8453": "base", "42161": "arbitrum-one", "56": "binance-smart-chain", "43114": "avalanche", "42220": "celo", "324": "zksync", }; // CoinGecko native coin IDs per chain const NATIVE_COIN_ID: Record = { "1": "ethereum", "10": "ethereum", "100": "dai", "137": "matic-network", "8453": "ethereum", "42161": "ethereum", "56": "binancecoin", "43114": "avalanche-2", "42220": "celo", "324": "ethereum", }; interface CacheEntry { prices: Map; // address (lowercase) → USD price nativePrice: number; cgAvailable: boolean; // true if CoinGecko successfully returned token data ts: number; } const TTL = 5 * 60 * 1000; // 5 minutes const cache = new Map(); const inFlight = new Map>(); async function cgFetch(url: string): Promise { const res = await fetch(url, { headers: { accept: "application/json" }, signal: AbortSignal.timeout(10000), }); if (res.status === 429) { console.warn("[price-feed] CoinGecko rate limited, waiting 60s..."); await new Promise((r) => setTimeout(r, 60000)); const retry = await fetch(url, { headers: { accept: "application/json" }, signal: AbortSignal.timeout(10000), }); if (!retry.ok) return null; return retry.json(); } if (!res.ok) return null; return res.json(); } /** Fetch native coin price for a chain */ export async function getNativePrice(chainId: string): Promise { const entry = cache.get(chainId); if (entry && Date.now() - entry.ts < TTL) return entry.nativePrice; const coinId = NATIVE_COIN_ID[chainId]; if (!coinId) return 0; const data = await cgFetch( `https://api.coingecko.com/api/v3/simple/price?ids=${coinId}&vs_currencies=usd`, ); return data?.[coinId]?.usd ?? 0; } /** Fetch token prices for a batch of contract addresses on a chain */ export async function getTokenPrices( chainId: string, addresses: string[], ): Promise<{ prices: Map; available: boolean }> { const platform = CHAIN_PLATFORM[chainId]; if (!platform || addresses.length === 0) return { prices: new Map(), available: false }; const lower = [...new Set(addresses.map((a) => a.toLowerCase()))]; const prices = new Map(); const data = await cgFetch( `https://api.coingecko.com/api/v3/simple/token_price/${platform}?contract_addresses=${lower.join(",")}&vs_currencies=usd`, ); if (data && !data.error_code) { for (const addr of lower) { if (data[addr]?.usd) prices.set(addr, data[addr].usd); } return { prices, available: true }; } return { prices, available: false }; } /** Fetch and cache all prices for a chain (native + tokens) */ async function fetchChainPrices( chainId: string, tokenAddresses: string[], ): Promise { const existing = cache.get(chainId); if (existing && Date.now() - existing.ts < TTL) return existing; // Deduplicate concurrent requests for same chain const key = chainId; const pending = inFlight.get(key); if (pending) return pending; const promise = (async (): Promise => { try { const [nativePrice, tokenResult] = await Promise.all([ getNativePrice(chainId), getTokenPrices(chainId, tokenAddresses), ]); const entry: CacheEntry = { prices: tokenResult.prices, nativePrice, cgAvailable: tokenResult.available, ts: Date.now(), }; cache.set(chainId, entry); return entry; } finally { inFlight.delete(key); } })(); inFlight.set(key, promise); return promise; } interface BalanceItem { tokenAddress: string | null; token: { name: string; symbol: string; decimals: number }; balance: string; fiatBalance: string; fiatConversion: string; } /** * Enrich balance items with USD prices from CoinGecko. * Fills in fiatBalance and fiatConversion for items that have "0" values. */ export async function enrichWithPrices( balances: BalanceItem[], chainId: string, options?: { filterSpam?: boolean }, ): Promise { // 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 const needsPricing = balances.some( (b) => BigInt(b.balance || "0") > 0n && (b.fiatBalance === "0" || b.fiatBalance === "" || !b.fiatBalance), ); if (!needsPricing) return balances; const tokenAddresses = balances .filter((b) => b.tokenAddress && b.tokenAddress !== "0x0000000000000000000000000000000000000000") .map((b) => b.tokenAddress!); try { const priceData = await fetchChainPrices(chainId, tokenAddresses); 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; } const balWei = BigInt(b.balance || "0"); if (balWei === 0n) return b; let price = 0; if (!b.tokenAddress || b.tokenAddress === "0x0000000000000000000000000000000000000000") { // Native token price = priceData.nativePrice; } else { price = priceData.prices.get(b.tokenAddress.toLowerCase()) ?? 0; } if (price === 0) return b; const decimals = b.token?.decimals ?? 18; const balHuman = Number(balWei) / Math.pow(10, decimals); const fiatValue = balHuman * price; return { ...b, fiatConversion: String(price), fiatBalance: String(fiatValue), }; }); // Only filter spam when CoinGecko data is available to verify against if (options?.filterSpam && priceData.cgAvailable) { 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; } }