init
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user