swaggerready
This commit is contained in:
138
apps/api/src/controllers/prices.controller.ts
Normal file
138
apps/api/src/controllers/prices.controller.ts
Normal file
@@ -0,0 +1,138 @@
|
||||
/**
|
||||
* 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';
|
||||
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' });
|
||||
}
|
||||
},
|
||||
};
|
||||
Reference in New Issue
Block a user