/** * USD price oracle for wallet balance responses + 24h change percentage. * * Data source: CoinGecko free API (https://api.coingecko.com/api/v3/simple/price). * Cache: KeyDB (Redis), TTL = 300s. * * Now also returns `change24h` (price change percent over rolling 24h) — used by * `/api/prices/dynamics`. Existing helpers `getPricesByIds` / `getPricesBySymbols` * остаются backward-compatible (возвращают только number | null) — для них достаточно * `usd` поля из cache. * * Security (см. план §"Security checklist"): * S1 — whitelist через getCoingeckoId → user input не попадает в URL. * S2 — лимит размеров вызовов через caller (controller `/prices`). * S3 — strict typeof/Number.isFinite/>=0 при чтении cache. * S4 — in-flight dedup (см. `_inflight` map) + cache. * S5 — никаких stack-trace'ов наружу; ошибки в logger. * S9 — CG API key, если задан, идёт ТОЛЬКО в header (не в URL). * S10 — `Number.isFinite` guard для usdValue (применяется в `wallet-ops.service.ts`). * S11 — жёсткий 5s AbortController timeout. * S12 — `null` ответ не кэшируем; только успешные числа уходят в Redis. */ import { getRedis } from '../config/redis'; import { logger } from '../lib/logger'; import { getCoingeckoId } from '../lib/token-registry'; import type { ChainCode } from '../lib/address-validators'; const COINGECKO_URL = 'https://api.coingecko.com/api/v3/simple/price'; const CACHE_TTL_SECONDS = 300; const CACHE_KEY_PREFIX = 'price:'; const FETCH_TIMEOUT_MS = 5000; const MAX_IDS_PER_REQUEST = 100; export interface PriceWithChange { usd: number; change24h: number | null; // например -1.38 (= -1.38%), 0.06, null если CG не отдал } interface CachedPrice { usd: number; change24h: number | null; ts: number; } /** In-flight dedup — несколько параллельных запросов одного id шлют ОДИН fetch. */ const _inflight = new Map>>(); function isValidPrice(n: unknown): n is number { return typeof n === 'number' && Number.isFinite(n) && n >= 0; } function isValidChange(n: unknown): n is number { // change24h может быть negative (падение цены), но конечное число return typeof n === 'number' && Number.isFinite(n); } function buildHeaders(): Record { const headers: Record = { Accept: 'application/json' }; const key = process.env.COINGECKO_API_KEY; if (key && key.length > 0) { headers['x-cg-demo-api-key'] = key; } return headers; } /** * Fetches CoinGecko /simple/price for a batch of coin ids. * Now includes `include_24hr_change=true` — отдаёт usd_24h_change поле. */ async function fetchCoingecko(ids: string[]): Promise> { const url = `${COINGECKO_URL}?ids=${ids.join(',')}&vs_currencies=usd&include_24hr_change=true`; const ctrl = new AbortController(); const t = setTimeout(() => ctrl.abort(), FETCH_TIMEOUT_MS); try { const res = await fetch(url, { signal: ctrl.signal, headers: buildHeaders(), }); if (!res.ok) { logger.warn(`CoinGecko HTTP ${res.status} for ${ids.length} ids`); const out: Record = {}; for (const id of ids) out[id] = null; return out; } const json = (await res.json()) as Record; const out: Record = {}; for (const id of ids) { const usd = json?.[id]?.usd; const change = json?.[id]?.usd_24h_change; if (isValidPrice(usd)) { out[id] = { usd, change24h: isValidChange(change) ? change : null, }; } else { out[id] = null; } } return out; } catch (err: any) { logger.warn(`CoinGecko fetch failed (${ids.length} ids): ${err?.message || 'unknown'}`); const out: Record = {}; for (const id of ids) out[id] = null; return out; } finally { clearTimeout(t); } } /** * Возвращает USD-цены + 24h change для списка CoinGecko ids. * Никогда не throws — degrades to `null` per-id. * * Cache: read-through KeyDB, 300s TTL. Только валидные usd кэшируются (S12). * Dedup: in-flight Map предотвращает дублирующиеся upstream вызовы (S4). */ export async function getPricesWithChangeByIds( ids: string[], ): Promise> { if (!Array.isArray(ids) || ids.length === 0) return {}; const uniqIds = Array.from(new Set(ids.filter((x) => typeof x === 'string' && x.length > 0))); if (uniqIds.length === 0) return {}; const result: Record = {}; let redis: ReturnType | null = null; try { redis = getRedis(); } catch { redis = null; } // 1) Read cache (pipeline) const misses: string[] = []; if (redis) { try { const pipeline = redis.pipeline(); for (const id of uniqIds) pipeline.get(CACHE_KEY_PREFIX + id); const cached = await pipeline.exec(); uniqIds.forEach((id, i) => { const tuple = cached?.[i]; const raw = tuple?.[1] as string | null | undefined; if (raw) { try { const parsed = JSON.parse(raw) as CachedPrice; if (isValidPrice(parsed?.usd)) { result[id] = { usd: parsed.usd, change24h: isValidChange(parsed?.change24h) ? parsed.change24h : null, }; return; } } catch { // S3 — невалидный JSON в cache → fall through к refetch. } } misses.push(id); }); } catch (err: any) { logger.warn(`Redis cache read failed: ${err?.message || 'unknown'}`); for (const id of uniqIds) { if (!(id in result)) misses.push(id); } } } else { for (const id of uniqIds) misses.push(id); } if (misses.length === 0) return result; // 2) Fetch misses в batches + in-flight dedup (S4). const fetched: Record = {}; for (let i = 0; i < misses.length; i += MAX_IDS_PER_REQUEST) { const batch = misses.slice(i, i + MAX_IDS_PER_REQUEST); const batchKey = batch.join('|'); let p = _inflight.get(batchKey); if (!p) { p = fetchCoingecko(batch).finally(() => _inflight.delete(batchKey)); _inflight.set(batchKey, p); } const batchResult = await p; Object.assign(fetched, batchResult); } // 3) Persist successes to cache (S12: skip nulls). if (redis) { try { const setP = redis.pipeline(); let writes = 0; for (const [id, val] of Object.entries(fetched)) { if (val && isValidPrice(val.usd)) { setP.set( CACHE_KEY_PREFIX + id, JSON.stringify({ usd: val.usd, change24h: val.change24h, ts: Date.now(), } satisfies CachedPrice), 'EX', CACHE_TTL_SECONDS, ); writes += 1; } } if (writes > 0) await setP.exec(); } catch (err: any) { logger.warn(`Redis cache write failed: ${err?.message || 'unknown'}`); } } // 4) Merge fetched into result. for (const id of misses) { result[id] = id in fetched ? fetched[id] : null; } return result; } /** * Backward-compatible thin wrapper: возвращает только usd (без change24h). * Все существующие callers (portfolio, swap quote USD enrichment) используют это. */ export async function getPricesByIds(ids: string[]): Promise> { const rich = await getPricesWithChangeByIds(ids); const out: Record = {}; for (const id of Object.keys(rich)) { out[id] = rich[id]?.usd ?? null; } return out; } /** * Convenience-обёртка для callers которые оперируют (chain, symbol) парами. * * Возвращает Map с ключами вида `"{CHAIN}:{SYMBOL}"` → price | null. * Symbol-ы НЕ из реестра → ключ присутствует, value = `null` (graceful). */ export async function getPricesBySymbols( pairs: { chain: ChainCode; symbol: string }[], ): Promise> { const out = new Map(); if (!Array.isArray(pairs) || pairs.length === 0) return out; const pairToId = new Map(); const idsToFetch = new Set(); for (const { chain, symbol } of pairs) { const key = `${chain}:${symbol}`; if (pairToId.has(key)) continue; const id = getCoingeckoId(chain, symbol); pairToId.set(key, id); if (id) idsToFetch.add(id); else out.set(key, null); } const prices = await getPricesByIds(Array.from(idsToFetch)); for (const [key, id] of pairToId.entries()) { if (out.has(key)) continue; if (!id) { out.set(key, null); continue; } out.set(key, prices[id] ?? null); } return out; } /** * Same as getPricesBySymbols но возвращает PriceWithChange. * Используется в /api/prices/dynamics. */ export async function getPricesWithChangeBySymbols( pairs: { chain: ChainCode; symbol: string }[], ): Promise> { const out = new Map(); if (!Array.isArray(pairs) || pairs.length === 0) return out; const pairToId = new Map(); const idsToFetch = new Set(); for (const { chain, symbol } of pairs) { const key = `${chain}:${symbol}`; if (pairToId.has(key)) continue; const id = getCoingeckoId(chain, symbol); pairToId.set(key, id); if (id) idsToFetch.add(id); else out.set(key, null); } const prices = await getPricesWithChangeByIds(Array.from(idsToFetch)); for (const [key, id] of pairToId.entries()) { if (out.has(key)) continue; if (!id) { out.set(key, null); continue; } out.set(key, prices[id] ?? null); } return out; }