223 lines
8.3 KiB
TypeScript
223 lines
8.3 KiB
TypeScript
/**
|
||
* 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' });
|
||
}
|
||
},
|
||
};
|