swaggerready
This commit is contained in:
235
apps/api/src/services/price-oracle.service.ts
Normal file
235
apps/api/src/services/price-oracle.service.ts
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user