/** * 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; 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 contract addresses on a chain (1 per request for free tier) */ export async function getTokenPrices( chainId: string, addresses: string[], ): Promise> { const platform = CHAIN_PLATFORM[chainId]; if (!platform || addresses.length === 0) return new Map(); const lower = [...new Set(addresses.map((a) => a.toLowerCase()))]; const result = new Map(); // CoinGecko free tier: 1 contract address per request, ~30 req/min // Process in batches of 3 with a short delay between batches const BATCH = 3; for (let i = 0; i < lower.length; i += BATCH) { const batch = lower.slice(i, i + BATCH); const fetches = batch.map(async (addr) => { const data = await cgFetch( `https://api.coingecko.com/api/v3/simple/token_price/${platform}?contract_addresses=${addr}&vs_currencies=usd`, ); if (data?.[addr]?.usd) result.set(addr, data[addr].usd); }); await Promise.allSettled(fetches); if (i + BATCH < lower.length) await new Promise((r) => setTimeout(r, 1500)); } return result; } /** 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, tokenPrices] = await Promise.all([ getNativePrice(chainId), getTokenPrices(chainId, tokenAddresses), ]); const entry: CacheEntry = { prices: tokenPrices, nativePrice, 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), }; }); 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; } }