Files
cryptowallet/apps/api/src/lib/uniswap-subgraph.ts
ZOMBIIIIIII f8403eb9f6 initalll
2026-06-11 12:03:51 +03:00

161 lines
6.0 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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;
}