This commit is contained in:
ZOMBIIIIIII
2026-06-11 12:03:51 +03:00
parent 399322973e
commit f8403eb9f6
23 changed files with 3998 additions and 6 deletions

View File

@@ -0,0 +1,160 @@
/**
* 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<PoolStats> {
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<PoolStats | null> {
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<number | null> {
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<string>,
pool.token1() as Promise<string>,
]);
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<number>,
t1.decimals() as Promise<number>,
t0.symbol().catch(() => '') as Promise<string>,
t1.symbol().catch(() => '') as Promise<string>,
t0.balanceOf(poolAddr) as Promise<ethers.BigNumber>,
t1.balanceOf(poolAddr) as Promise<ethers.BigNumber>,
]);
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;
}