/** * GET /api/prices — USD prices for selected token symbols. * * Security: * S1 — whitelist через `getCoingeckoId`. Любой symbol вне registry → 400. * S2 — лимит max 50 (symbol, chain) пар. Иначе → 400. * S5 — общий 502 при failure, без stack trace. * S7 — auth provided by router middleware. */ import { Request, Response } from 'express'; import { getCoingeckoId } from '../lib/token-registry'; import { ALL_CHAINS } from '../services/wallet-generator.service'; import { getPricesBySymbols } from '../services/price-oracle.service'; // getPricesWithChangeByIds импортируется dynamic'но в getDynamics handler ниже. import type { ChainCode } from '../lib/address-validators'; import { logger } from '../lib/logger'; const MAX_SYMBOLS_PER_REQUEST = 50; const ALLOWED_CHAINS = new Set(ALL_CHAINS); const SYMBOL_RE = /^[A-Z0-9]{1,16}$/; function isChain(v: unknown): v is ChainCode { return typeof v === 'string' && ALLOWED_CHAINS.has(v as ChainCode); } export const PricesController = { /** * GET /api/prices?symbols=BTC,ETH,USDT&chain=ETH * * Params: * - symbols: comma-separated list, max 50. Каждый symbol должен быть в whitelist. * - chain (опционально): chain для disambiguation (USDT на ETH vs USDT на BSC). * Если не указан — для каждого symbol fallback порядок: ETH → BSC → SOL → TRX → BTC. * Native symbol (BTC/ETH/...) всегда matches its chain. * * Response 200: * { success: true, data: { "BTC": { "usd": 67432.12 }, "ETH": { "usd": 3210.45 }, "FOO": { "usd": null } } } */ async getPrices(req: Request, res: Response) { try { const rawSymbols = String(req.query.symbols || '').trim(); if (!rawSymbols) { res.status(400).json({ success: false, error: 'symbols query param is required (csv)' }); return; } const requestedChain = req.query.chain ? String(req.query.chain).toUpperCase() : null; if (requestedChain && !isChain(requestedChain)) { res.status(400).json({ success: false, error: 'Invalid chain parameter' }); return; } const symbols = rawSymbols .split(',') .map((s) => s.trim().toUpperCase()) .filter((s) => s.length > 0); if (symbols.length === 0) { res.status(400).json({ success: false, error: 'symbols list is empty' }); return; } if (symbols.length > MAX_SYMBOLS_PER_REQUEST) { res.status(400).json({ success: false, error: `Too many symbols (max ${MAX_SYMBOLS_PER_REQUEST})`, }); return; } // Strict symbol shape (S1 belt-and-suspenders). for (const s of symbols) { if (!SYMBOL_RE.test(s)) { res.status(400).json({ success: false, error: `Invalid symbol: ${s}` }); return; } } // Build (chain, symbol) pairs. // Fallback resolution order при отсутствии явного chain: // native symbol == chain code → that chain; // иначе пробуем ETH, BSC, SOL, TRX, BTC по очереди. const fallbackChains: ChainCode[] = ['ETH', 'BSC', 'SOL', 'TRX', 'BTC']; const pairs: { chain: ChainCode; symbol: string; key: string }[] = []; for (const sym of symbols) { if (requestedChain) { pairs.push({ chain: requestedChain as ChainCode, symbol: sym, key: sym }); continue; } let resolvedChain: ChainCode | null = null; if (ALLOWED_CHAINS.has(sym as ChainCode)) { resolvedChain = sym as ChainCode; } else { for (const c of fallbackChains) { if (getCoingeckoId(c, sym)) { resolvedChain = c; break; } } } if (!resolvedChain) { // Symbol не находится ни в одной chain → 400 (S1: whitelist enforcement). res.status(400).json({ success: false, error: `Unknown symbol: ${sym}` }); return; } pairs.push({ chain: resolvedChain, symbol: sym, key: sym }); } // Если явный chain задан — повторная проверка whitelist для каждого symbol // (native symbol для chain'а тоже разрешён). if (requestedChain) { for (const p of pairs) { if (!getCoingeckoId(p.chain, p.symbol)) { res.status(400).json({ success: false, error: `Unknown symbol ${p.symbol} for chain ${p.chain}`, }); return; } } } const prices = await getPricesBySymbols( pairs.map((p) => ({ chain: p.chain, symbol: p.symbol })), ); const data: Record = {}; for (const p of pairs) { const lookupKey = `${p.chain}:${p.symbol}`; data[p.key] = { usd: prices.get(lookupKey) ?? null }; } res.json({ success: true, data }); } catch (err: any) { logger.error(`getPrices failed: ${err?.stack || err?.message || 'unknown'}`); res.status(502).json({ success: false, error: 'Upstream price oracle error' }); } }, /** * GET /api/prices/dynamics?symbols=BTC,ETH,BNB,SOL,TRX * * Возвращает USD-цену + 24h % изменения для списка symbols. * Default symbols (если query не задан): BTC,ETH,BNB,SOL,TRX. * Source: CoinGecko `include_24hr_change=true` (rolling 24h, не anchored). * * Response 200: * { success: true, data: { "BTC": { "usd": 67432.12, "change24h": -1.38 }, ... } } */ async getDynamics(req: Request, res: Response) { try { const rawSymbols = String(req.query.symbols || '').trim(); const symbols = rawSymbols ? rawSymbols.split(',').map((s) => s.trim().toUpperCase()).filter((s) => s.length > 0) : ['BTC', 'ETH', 'BNB', 'SOL', 'TRX']; if (symbols.length === 0) { res.status(400).json({ success: false, error: 'symbols list is empty' }); return; } if (symbols.length > MAX_SYMBOLS_PER_REQUEST) { res.status(400).json({ success: false, error: `Too many symbols (max ${MAX_SYMBOLS_PER_REQUEST})`, }); return; } for (const s of symbols) { if (!SYMBOL_RE.test(s)) { res.status(400).json({ success: false, error: `Invalid symbol: ${s}` }); return; } } // Resolve каждый symbol в CoinGecko id напрямую. // Native tickers: BTC=bitcoin, ETH=ethereum, BNB=binancecoin, SOL=solana, TRX=tron. // Для non-native: пытаемся getCoingeckoId через chain fallback. const NATIVE_TICKER_TO_COINGECKO: Record = { BTC: 'bitcoin', ETH: 'ethereum', BNB: 'binancecoin', SOL: 'solana', TRX: 'tron', }; const fallbackChains: ChainCode[] = ['ETH', 'BSC', 'SOL', 'TRX', 'BTC']; const symbolToCgId = new Map(); for (const sym of symbols) { let cgId: string | null = NATIVE_TICKER_TO_COINGECKO[sym] ?? null; if (!cgId) { for (const c of fallbackChains) { const id = getCoingeckoId(c, sym); if (id) { cgId = id; break; } } } if (!cgId) { res.status(400).json({ success: false, error: `Unknown symbol: ${sym}` }); return; } symbolToCgId.set(sym, cgId); } const { getPricesWithChangeByIds } = await import('../services/price-oracle.service'); const rich = await getPricesWithChangeByIds(Array.from(new Set(symbolToCgId.values()))); const data: Record = {}; for (const sym of symbols) { const cgId = symbolToCgId.get(sym)!; const v = rich[cgId]; data[sym] = { usd: v?.usd ?? null, change24h: v?.change24h ?? null, }; } res.json({ success: true, data }); } catch (err: any) { logger.error(`getDynamics failed: ${err?.stack || err?.message || 'unknown'}`); res.status(502).json({ success: false, error: 'Upstream price oracle error' }); } }, };