Files
cryptowallet/apps/api/src/services/price-oracle.service.ts
ZOMBIIIIIII e86ff7c063 init
2026-05-28 13:51:30 +03:00

307 lines
10 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.
/**
* USD price oracle for wallet balance responses + 24h change percentage.
*
* Data source: CoinGecko free API (https://api.coingecko.com/api/v3/simple/price).
* Cache: KeyDB (Redis), TTL = 300s.
*
* Now also returns `change24h` (price change percent over rolling 24h) — used by
* `/api/prices/dynamics`. Existing helpers `getPricesByIds` / `getPricesBySymbols`
* остаются backward-compatible (возвращают только number | null) — для них достаточно
* `usd` поля из cache.
*
* 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;
export interface PriceWithChange {
usd: number;
change24h: number | null; // например -1.38 (= -1.38%), 0.06, null если CG не отдал
}
interface CachedPrice {
usd: number;
change24h: number | null;
ts: number;
}
/** In-flight dedup — несколько параллельных запросов одного id шлют ОДИН fetch. */
const _inflight = new Map<string, Promise<Record<string, PriceWithChange | null>>>();
function isValidPrice(n: unknown): n is number {
return typeof n === 'number' && Number.isFinite(n) && n >= 0;
}
function isValidChange(n: unknown): n is number {
// change24h может быть negative (падение цены), но конечное число
return typeof n === 'number' && Number.isFinite(n);
}
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) {
headers['x-cg-demo-api-key'] = key;
}
return headers;
}
/**
* Fetches CoinGecko /simple/price for a batch of coin ids.
* Now includes `include_24hr_change=true` — отдаёт usd_24h_change поле.
*/
async function fetchCoingecko(ids: string[]): Promise<Record<string, PriceWithChange | null>> {
const url = `${COINGECKO_URL}?ids=${ids.join(',')}&vs_currencies=usd&include_24hr_change=true`;
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) {
logger.warn(`CoinGecko HTTP ${res.status} for ${ids.length} ids`);
const out: Record<string, PriceWithChange | null> = {};
for (const id of ids) out[id] = null;
return out;
}
const json = (await res.json()) as Record<string, { usd?: unknown; usd_24h_change?: unknown }>;
const out: Record<string, PriceWithChange | null> = {};
for (const id of ids) {
const usd = json?.[id]?.usd;
const change = json?.[id]?.usd_24h_change;
if (isValidPrice(usd)) {
out[id] = {
usd,
change24h: isValidChange(change) ? change : null,
};
} else {
out[id] = null;
}
}
return out;
} catch (err: any) {
logger.warn(`CoinGecko fetch failed (${ids.length} ids): ${err?.message || 'unknown'}`);
const out: Record<string, PriceWithChange | null> = {};
for (const id of ids) out[id] = null;
return out;
} finally {
clearTimeout(t);
}
}
/**
* Возвращает USD-цены + 24h change для списка CoinGecko ids.
* Никогда не throws — degrades to `null` per-id.
*
* Cache: read-through KeyDB, 300s TTL. Только валидные usd кэшируются (S12).
* Dedup: in-flight Map предотвращает дублирующиеся upstream вызовы (S4).
*/
export async function getPricesWithChangeByIds(
ids: string[],
): Promise<Record<string, PriceWithChange | null>> {
if (!Array.isArray(ids) || ids.length === 0) return {};
const uniqIds = Array.from(new Set(ids.filter((x) => typeof x === 'string' && x.length > 0)));
if (uniqIds.length === 0) return {};
const result: Record<string, PriceWithChange | null> = {};
let redis: ReturnType<typeof getRedis> | null = null;
try {
redis = getRedis();
} catch {
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] = {
usd: parsed.usd,
change24h: isValidChange(parsed?.change24h) ? parsed.change24h : null,
};
return;
}
} catch {
// S3 — невалидный JSON в cache → fall through к refetch.
}
}
misses.push(id);
});
} catch (err: any) {
logger.warn(`Redis cache read failed: ${err?.message || 'unknown'}`);
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 + in-flight dedup (S4).
const fetched: Record<string, PriceWithChange | 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 (val && isValidPrice(val.usd)) {
setP.set(
CACHE_KEY_PREFIX + id,
JSON.stringify({
usd: val.usd,
change24h: val.change24h,
ts: Date.now(),
} satisfies CachedPrice),
'EX',
CACHE_TTL_SECONDS,
);
writes += 1;
}
}
if (writes > 0) await setP.exec();
} catch (err: any) {
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;
}
/**
* Backward-compatible thin wrapper: возвращает только usd (без change24h).
* Все существующие callers (portfolio, swap quote USD enrichment) используют это.
*/
export async function getPricesByIds(ids: string[]): Promise<Record<string, number | null>> {
const rich = await getPricesWithChangeByIds(ids);
const out: Record<string, number | null> = {};
for (const id of Object.keys(rich)) {
out[id] = rich[id]?.usd ?? null;
}
return out;
}
/**
* Convenience-обёртка для callers которые оперируют (chain, symbol) парами.
*
* Возвращает Map с ключами вида `"{CHAIN}:{SYMBOL}"` → price | null.
* Symbol-ы НЕ из реестра → ключ присутствует, value = `null` (graceful).
*/
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;
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;
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;
}
/**
* Same as getPricesBySymbols но возвращает PriceWithChange.
* Используется в /api/prices/dynamics.
*/
export async function getPricesWithChangeBySymbols(
pairs: { chain: ChainCode; symbol: string }[],
): Promise<Map<string, PriceWithChange | null>> {
const out = new Map<string, PriceWithChange | null>();
if (!Array.isArray(pairs) || pairs.length === 0) return out;
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;
const id = getCoingeckoId(chain, symbol);
pairToId.set(key, id);
if (id) idsToFetch.add(id);
else out.set(key, null);
}
const prices = await getPricesWithChangeByIds(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;
}