307 lines
10 KiB
TypeScript
307 lines
10 KiB
TypeScript
/**
|
||
* 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;
|
||
}
|