Files
cryptowallet/apps/api/src/controllers/prices.controller.ts
ZOMBIIIIIII e86ff7c063 init
2026-05-28 13:51:30 +03:00

223 lines
8.3 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.
/**
* 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<ChainCode>(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<string, { usd: number | null }> = {};
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<string, string> = {
BTC: 'bitcoin',
ETH: 'ethereum',
BNB: 'binancecoin',
SOL: 'solana',
TRX: 'tron',
};
const fallbackChains: ChainCode[] = ['ETH', 'BSC', 'SOL', 'TRX', 'BTC'];
const symbolToCgId = new Map<string, string>();
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<string, { usd: number | null; change24h: number | null }> = {};
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' });
}
},
};