/** * Uniswap v3 pool stats — TVL / 24h volume / 24h fees / APR. * * Источник: GeckoTerminal API (бесплатный, БЕЗ ключа — та же on-chain data network что у Uniswap). * Раньше использовался The Graph gateway, но он требует платный THEGRAPH_API_KEY (hosted service * закрыт) → без ключа возвращал null. GeckoTerminal не требует авторизации. * * fees24h выводится из volume24h × feeTier (не полагаемся на отдельное поле GT). * APR = fees24h / TVL × 365 × 100. * * Fallback (если GeckoTerminal недоступен): on-chain TVL = balanceOf(token0/token1) × USD-цена из * price-oracle (CoinGecko). Объём/комиссии/APR в фолбэке остаются null (on-chain недоступны дёшево). * * Только чтение, НИКОГДА не влияет на суммы транзакций. KeyDB-кэш 5 мин. Всё graceful → null-поля. */ import { ethers } from 'ethers'; import { getRedis } from '../config/redis'; import { logger } from './logger'; import { ETH_RPCS, UNIV3_POOL_ABI, ERC20_META_ABI } from './defi-contracts'; import { pickProxiedEvmProvider } from './outbound-proxy'; import { getPricesBySymbols } from '../services/price-oracle.service'; const GECKOTERMINAL_POOL_URL = (addr: string) => `https://api.geckoterminal.com/api/v2/networks/eth/pools/${addr}`; const CACHE_TTL_SEC = 300; const HTTP_TIMEOUT_MS = 8000; export interface PoolStats { poolAddress: string; totalValueLockedUSD: number | null; volumeUSD24h: number | null; feesUSD24h: number | null; aprPercent: number | null; } /** * Статистика пула (с кэшем). null-поля если источники недоступны. * @param feeTier uint24 fee пула (напр. 3000 = 0.3%) — нужен для расчёта fees/APR. Если не задан, * fees/APR останутся null. */ export async function getPoolStats(poolAddress: string, feeTier?: number): Promise { const id = poolAddress.toLowerCase(); const cacheKey = `univ3:poolstats:${id}`; try { const cached = await getRedis().get(cacheKey); if (cached) return JSON.parse(cached) as PoolStats; } catch { /* ignore cache errors */ } const empty: PoolStats = { poolAddress: id, totalValueLockedUSD: null, volumeUSD24h: null, feesUSD24h: null, aprPercent: null, }; let stats = empty; // ── Primary: GeckoTerminal ── const gt = await fetchGeckoTerminal(id, feeTier); if (gt) { stats = gt; } else { // ── Fallback: on-chain TVL (balances × prices) ── const onchainTvl = await fetchOnChainTvl(id).catch(() => null); if (onchainTvl !== null) { stats = { ...empty, totalValueLockedUSD: onchainTvl }; } } try { await getRedis().set(cacheKey, JSON.stringify(stats), 'EX', CACHE_TTL_SEC); } catch { /* ignore */ } return stats; } async function fetchGeckoTerminal(id: string, feeTier?: number): Promise { const controller = new AbortController(); const t = setTimeout(() => controller.abort(), HTTP_TIMEOUT_MS); try { const res = await fetch(GECKOTERMINAL_POOL_URL(id), { headers: { Accept: 'application/json' }, signal: controller.signal, }); if (!res.ok) { logger.warn(`GeckoTerminal HTTP ${res.status} for pool ${id}`); return null; } const j: any = await res.json(); const attr = j?.data?.attributes; if (!attr) return null; const tvl = num(attr.reserve_in_usd); const vol24h = num(attr.volume_usd?.h24); // fees = volume × feeRate. feeTier uint24: 3000 = 0.3% = 3000/1e6. const fees24h = vol24h !== null && feeTier ? vol24h * (feeTier / 1_000_000) : null; const apr = fees24h !== null && tvl && tvl > 0 ? (fees24h / tvl) * 365 * 100 : null; return { poolAddress: id, totalValueLockedUSD: tvl, volumeUSD24h: vol24h, feesUSD24h: fees24h, aprPercent: apr, }; } catch (err: any) { logger.warn(`GeckoTerminal fetch failed for ${id}: ${err?.message}`); return null; } finally { clearTimeout(t); } } /** Best-effort on-chain TVL: balanceOf(token0)+balanceOf(token1) × USD-цена по символу. */ async function fetchOnChainTvl(poolAddr: string): Promise { const provider = await pickProxiedEvmProvider(ETH_RPCS, 1); const pool = new ethers.Contract(poolAddr, UNIV3_POOL_ABI, provider); const [token0, token1] = await Promise.all([ pool.token0() as Promise, pool.token1() as Promise, ]); const t0 = new ethers.Contract(token0, ERC20_META_ABI, provider); const t1 = new ethers.Contract(token1, ERC20_META_ABI, provider); const [dec0, dec1, sym0, sym1, bal0, bal1] = await Promise.all([ t0.decimals() as Promise, t1.decimals() as Promise, t0.symbol().catch(() => '') as Promise, t1.symbol().catch(() => '') as Promise, t0.balanceOf(poolAddr) as Promise, t1.balanceOf(poolAddr) as Promise, ]); const prices = await getPricesBySymbols([ { chain: 'ETH', symbol: normSym(sym0) }, { chain: 'ETH', symbol: normSym(sym1) }, ]); const p0 = prices.get(`ETH:${normSym(sym0)}`); const p1 = prices.get(`ETH:${normSym(sym1)}`); if (p0 == null && p1 == null) return null; const human = (bal: ethers.BigNumber, dec: number) => Number(ethers.utils.formatUnits(bal, dec)); let tvl = 0; if (p0 != null) tvl += human(bal0, Number(dec0)) * p0; if (p1 != null) tvl += human(bal1, Number(dec1)) * p1; return Number.isFinite(tvl) && tvl > 0 ? tvl : null; } /** WETH→ETH для резолва цены через token-registry (WETH часто не зарегистрирован отдельно). */ function normSym(sym: string): string { const s = (sym || '').toUpperCase(); return s === 'WETH' ? 'ETH' : s; } function num(v: unknown): number | null { const n = Number(v); return Number.isFinite(n) ? n : null; }