This commit is contained in:
ZOMBIIIIIII
2026-05-28 13:51:30 +03:00
parent d2086b86e3
commit e86ff7c063
25 changed files with 4214 additions and 437 deletions

View File

@@ -1,9 +1,14 @@
/**
* USD price oracle for wallet balance responses.
* 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`).
@@ -25,26 +30,35 @@ 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.
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, number | null>>>();
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) {
// 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;
@@ -52,10 +66,10 @@ function buildHeaders(): Record<string, string> {
/**
* Fetches CoinGecko /simple/price for a batch of coin ids.
* Internal — caller must ensure `ids.length > 0 && ids.length <= MAX_IDS_PER_REQUEST`.
* Now includes `include_24hr_change=true` — отдаёт usd_24h_change поле.
*/
async function fetchCoingecko(ids: string[]): Promise<Record<string, number | null>> {
const url = `${COINGECKO_URL}?ids=${ids.join(',')}&vs_currencies=usd`;
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 {
@@ -64,22 +78,29 @@ async function fetchCoingecko(ids: string[]): Promise<Record<string, number | nu
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> = {};
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 }>;
const out: Record<string, number | null> = {};
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;
out[id] = isValidPrice(usd) ? usd : null;
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, number | null> = {};
const out: Record<string, PriceWithChange | null> = {};
for (const id of ids) out[id] = null;
return out;
} finally {
@@ -88,25 +109,25 @@ async function fetchCoingecko(ids: string[]): Promise<Record<string, number | nu
}
/**
* Возвращает USD-цены для списка CoinGecko ids.
* Возвращает USD-цены + 24h change для списка CoinGecko ids.
* Никогда не throws — degrades to `null` per-id.
*
* Cache: read-through KeyDB, 300s TTL. Только валидные числа кэшируются (S12).
* Cache: read-through KeyDB, 300s TTL. Только валидные usd кэшируются (S12).
* Dedup: in-flight Map предотвращает дублирующиеся upstream вызовы (S4).
*/
export async function getPricesByIds(ids: string[]): Promise<Record<string, number | null>> {
export async function getPricesWithChangeByIds(
ids: string[],
): Promise<Record<string, PriceWithChange | 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> = {};
const result: Record<string, PriceWithChange | null> = {};
let redis: ReturnType<typeof getRedis> | null = null;
try {
redis = getRedis();
} catch {
// Redis singleton недоступен — продолжаем без cache, сразу идём в CG.
redis = null;
}
@@ -124,7 +145,10 @@ export async function getPricesByIds(ids: string[]): Promise<Record<string, numb
try {
const parsed = JSON.parse(raw) as CachedPrice;
if (isValidPrice(parsed?.usd)) {
result[id] = parsed.usd;
result[id] = {
usd: parsed.usd,
change24h: isValidChange(parsed?.change24h) ? parsed.change24h : null,
};
return;
}
} catch {
@@ -135,7 +159,6 @@ export async function getPricesByIds(ids: string[]): Promise<Record<string, numb
});
} 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);
}
@@ -146,8 +169,8 @@ export async function getPricesByIds(ids: string[]): Promise<Record<string, numb
if (misses.length === 0) return result;
// 2) Fetch misses в batches (S2-style guard) + in-flight dedup (S4).
const fetched: Record<string, number | null> = {};
// 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('|');
@@ -167,10 +190,14 @@ export async function getPricesByIds(ids: string[]): Promise<Record<string, numb
const setP = redis.pipeline();
let writes = 0;
for (const [id, val] of Object.entries(fetched)) {
if (isValidPrice(val)) {
if (val && isValidPrice(val.usd)) {
setP.set(
CACHE_KEY_PREFIX + id,
JSON.stringify({ usd: val, ts: Date.now() } satisfies CachedPrice),
JSON.stringify({
usd: val.usd,
change24h: val.change24h,
ts: Date.now(),
} satisfies CachedPrice),
'EX',
CACHE_TTL_SECONDS,
);
@@ -179,7 +206,6 @@ export async function getPricesByIds(ids: string[]): Promise<Record<string, numb
}
if (writes > 0) await setP.exec();
} catch (err: any) {
// Cache write failure → не критично, продолжаем.
logger.warn(`Redis cache write failed: ${err?.message || 'unknown'}`);
}
}
@@ -192,14 +218,24 @@ export async function getPricesByIds(ids: string[]): Promise<Record<string, numb
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.
* Ключ совпадает с тем что caller затем использует на lookup'е.
*
* Symbol-ы НЕ из реестра → ключ присутствует, value = `null` (graceful).
* Никаких throw'ов, никаких побочек кроме cache writes.
*/
export async function getPricesBySymbols(
pairs: { chain: ChainCode; symbol: string }[],
@@ -207,13 +243,12 @@ export async function getPricesBySymbols(
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
if (pairToId.has(key)) continue;
const id = getCoingeckoId(chain, symbol);
pairToId.set(key, id);
if (id) idsToFetch.add(id);
@@ -233,3 +268,39 @@ export async function getPricesBySymbols(
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;
}