161 lines
6.0 KiB
TypeScript
161 lines
6.0 KiB
TypeScript
/**
|
||
* 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;
|
||
}
|