swaggerready

This commit is contained in:
ZOMBIIIIIII
2026-05-14 01:11:20 +03:00
parent 0661fffb88
commit 53635806d6
9 changed files with 1139 additions and 56 deletions

View File

@@ -0,0 +1,235 @@
/**
* USD price oracle for wallet balance responses.
*
* Data source: CoinGecko free API (https://api.coingecko.com/api/v3/simple/price).
* Cache: KeyDB (Redis), TTL = 300s.
*
* 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; // CoinGecko allows ~250, мы консервативно 100.
interface CachedPrice {
usd: number;
ts: number;
}
/** In-flight dedup — несколько параллельных запросов одного id шлют ОДИН fetch. */
const _inflight = new Map<string, Promise<Record<string, number | null>>>();
function isValidPrice(n: unknown): n is number {
return typeof n === 'number' && Number.isFinite(n) && n >= 0;
}
function buildHeaders(): Record<string, string> {
const headers: Record<string, string> = { Accept: 'application/json' };
const key = process.env.COINGECKO_API_KEY;
if (key && key.length > 0) {
// CoinGecko Demo API key → `x-cg-demo-api-key`. Pro → `x-cg-pro-api-key`.
// Не печатаем header нигде, см. S9.
headers['x-cg-demo-api-key'] = key;
}
return headers;
}
/**
* Fetches CoinGecko /simple/price for a batch of coin ids.
* Internal — caller must ensure `ids.length > 0 && ids.length <= MAX_IDS_PER_REQUEST`.
*/
async function fetchCoingecko(ids: string[]): Promise<Record<string, number | null>> {
const url = `${COINGECKO_URL}?ids=${ids.join(',')}&vs_currencies=usd`;
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) {
// S5: не логируем URL целиком (содержит query string).
logger.warn(`CoinGecko HTTP ${res.status} for ${ids.length} ids`);
const out: Record<string, number | null> = {};
for (const id of ids) out[id] = null;
return out;
}
const json = (await res.json()) as Record<string, { usd?: unknown }>;
const out: Record<string, number | null> = {};
for (const id of ids) {
const usd = json?.[id]?.usd;
out[id] = isValidPrice(usd) ? usd : null;
}
return out;
} catch (err: any) {
logger.warn(`CoinGecko fetch failed (${ids.length} ids): ${err?.message || 'unknown'}`);
const out: Record<string, number | null> = {};
for (const id of ids) out[id] = null;
return out;
} finally {
clearTimeout(t);
}
}
/**
* Возвращает USD-цены для списка CoinGecko ids.
* Никогда не throws — degrades to `null` per-id.
*
* Cache: read-through KeyDB, 300s TTL. Только валидные числа кэшируются (S12).
* Dedup: in-flight Map предотвращает дублирующиеся upstream вызовы (S4).
*/
export async function getPricesByIds(ids: string[]): Promise<Record<string, number | null>> {
if (!Array.isArray(ids) || ids.length === 0) return {};
// Дедупликация ids (на случай если caller передал duplicates).
const uniqIds = Array.from(new Set(ids.filter((x) => typeof x === 'string' && x.length > 0)));
if (uniqIds.length === 0) return {};
const result: Record<string, number | null> = {};
let redis: ReturnType<typeof getRedis> | null = null;
try {
redis = getRedis();
} catch {
// Redis singleton недоступен — продолжаем без cache, сразу идём в CG.
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] = parsed.usd;
return;
}
} catch {
// S3 — невалидный JSON в cache → fall through к refetch.
}
}
misses.push(id);
});
} catch (err: any) {
logger.warn(`Redis cache read failed: ${err?.message || 'unknown'}`);
// Cache miss for ALL ids — degrade to upstream fetch.
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 (S2-style guard) + in-flight dedup (S4).
const fetched: Record<string, number | null> = {};
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 (isValidPrice(val)) {
setP.set(
CACHE_KEY_PREFIX + id,
JSON.stringify({ usd: val, ts: Date.now() } satisfies CachedPrice),
'EX',
CACHE_TTL_SECONDS,
);
writes += 1;
}
}
if (writes > 0) await setP.exec();
} catch (err: any) {
// Cache write failure → не критично, продолжаем.
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;
}
/**
* Convenience-обёртка для callers которые оперируют (chain, symbol) парами.
*
* Возвращает Map с ключами вида `"{CHAIN}:{SYMBOL}"` → price | null.
* Ключ совпадает с тем что caller затем использует на lookup'е.
*
* Symbol-ы НЕ из реестра → ключ присутствует, value = `null` (graceful).
* Никаких throw'ов, никаких побочек кроме cache writes.
*/
export async function getPricesBySymbols(
pairs: { chain: ChainCode; symbol: string }[],
): Promise<Map<string, number | null>> {
const out = new Map<string, number | null>();
if (!Array.isArray(pairs) || pairs.length === 0) return out;
// (chain:symbol) → coingeckoId | null
const pairToId = new Map<string, string | null>();
const idsToFetch = new Set<string>();
for (const { chain, symbol } of pairs) {
const key = `${chain}:${symbol}`;
if (pairToId.has(key)) continue; // dedup
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;
}