initvglidrbtgrthijl;
This commit is contained in:
@@ -8,6 +8,7 @@ import { env } from '../config/env';
|
||||
import { getEvmTokens, getTrxTokens, getSolTokens } from '../lib/token-registry';
|
||||
import { getPricesBySymbols } from './price-oracle.service';
|
||||
import { logger } from '../lib/logger';
|
||||
import { getRedis } from '../config/redis';
|
||||
|
||||
export type ChainCode = 'ETH' | 'BTC' | 'SOL' | 'TRX' | 'BSC';
|
||||
|
||||
@@ -211,6 +212,126 @@ export async function getBalance(chain: ChainCode, address: string): Promise<Bal
|
||||
return result;
|
||||
}
|
||||
|
||||
// ─────────────────────── PORTFOLIO (aggregate всех 5 сетей) ─────────
|
||||
|
||||
export interface ChainPortfolio extends BalanceResult {
|
||||
/** Сумма usdValue по native + всем токенам chain'а. `null` если все цены недоступны. */
|
||||
totalUsd: number | null;
|
||||
/** true = данные из KeyDB cache (RPC chain'а упал в этом запросе). */
|
||||
stale: boolean;
|
||||
/** Unix ms когда данные были обновлены (fresh fetch). */
|
||||
lastUpdated: number;
|
||||
/** Причина почему stale (только если stale=true). */
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface PortfolioResult {
|
||||
/** Grand sum по всем сетям. Округлено до 8 знаков. */
|
||||
totalUsd: number;
|
||||
/** true если хотя бы одна сеть в stale/error состоянии. */
|
||||
hasErrors: boolean;
|
||||
/** Per-chain breakdown. `null` для chain'а если ни fresh ни cache нет. */
|
||||
perChain: Record<ChainCode, ChainPortfolio | null>;
|
||||
}
|
||||
|
||||
const PORTFOLIO_CACHE_TTL_SEC = 3600; // 1 час stale-fallback
|
||||
const PORTFOLIO_CACHE_PREFIX = 'portfolio:'; // ключ: portfolio:{userId}:{chain}
|
||||
|
||||
function computeChainTotalUsd(b: BalanceResult): number | null {
|
||||
let total = 0;
|
||||
let anyValid = false;
|
||||
const add = (amt: FormattedAmount | undefined): void => {
|
||||
const v = amt?.usdValue;
|
||||
if (typeof v === 'number' && Number.isFinite(v)) {
|
||||
total += v;
|
||||
anyValid = true;
|
||||
}
|
||||
};
|
||||
add(b.native);
|
||||
for (const a of Object.values(b.tokens ?? {})) add(a);
|
||||
return anyValid ? roundUsd(total) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Aggregate balance по всем 5 сетям. Параллельно дёргает `getBalance(chain, address)` для каждой,
|
||||
* сохраняет успешные ответы в KeyDB (TTL 1 час). При сбое RPC отдельной сети — возвращает
|
||||
* последний кэшированный snapshot с пометкой `stale:true`, чтобы UI никогда не показывал 0.
|
||||
*
|
||||
* Не throws — даже при полной недоступности всех сетей вернёт totalUsd=0 + hasErrors=true.
|
||||
*/
|
||||
export async function getPortfolio(
|
||||
userId: string,
|
||||
addresses: Record<ChainCode, string>,
|
||||
): Promise<PortfolioResult> {
|
||||
const chains: ChainCode[] = ['ETH', 'BSC', 'BTC', 'TRX', 'SOL'];
|
||||
|
||||
const settled = await Promise.allSettled(
|
||||
chains.map((c) => {
|
||||
const addr = addresses[c];
|
||||
if (!addr) return Promise.reject(new Error(`No ${c} address for user`));
|
||||
return getBalance(c, addr);
|
||||
}),
|
||||
);
|
||||
|
||||
let redis: ReturnType<typeof getRedis> | null = null;
|
||||
try { redis = getRedis(); } catch { redis = null; }
|
||||
|
||||
const perChain: Record<string, ChainPortfolio | null> = {};
|
||||
let totalUsd = 0;
|
||||
let hasErrors = false;
|
||||
const now = Date.now();
|
||||
|
||||
for (let i = 0; i < chains.length; i++) {
|
||||
const chain = chains[i];
|
||||
const res = settled[i];
|
||||
const cacheKey = `${PORTFOLIO_CACHE_PREFIX}${userId}:${chain}`;
|
||||
|
||||
if (res.status === 'fulfilled') {
|
||||
const balance = res.value;
|
||||
const chainTotal = computeChainTotalUsd(balance);
|
||||
const entry: ChainPortfolio = {
|
||||
...balance,
|
||||
totalUsd: chainTotal,
|
||||
stale: false,
|
||||
lastUpdated: now,
|
||||
};
|
||||
perChain[chain] = entry;
|
||||
if (typeof chainTotal === 'number') totalUsd += chainTotal;
|
||||
// Cache fire-and-forget
|
||||
if (redis) {
|
||||
redis
|
||||
.set(cacheKey, JSON.stringify(entry), 'EX', PORTFOLIO_CACHE_TTL_SEC)
|
||||
.catch((err) => logger.warn(`portfolio cache write ${chain} failed: ${err?.message}`));
|
||||
}
|
||||
} else {
|
||||
hasErrors = true;
|
||||
const reason = String((res.reason as any)?.message || 'unknown');
|
||||
// Попробуем cached fallback
|
||||
let cached: ChainPortfolio | null = null;
|
||||
if (redis) {
|
||||
try {
|
||||
const raw = await redis.get(cacheKey);
|
||||
if (raw) cached = JSON.parse(raw) as ChainPortfolio;
|
||||
} catch (err: any) {
|
||||
logger.warn(`portfolio cache read ${chain} failed: ${err?.message}`);
|
||||
}
|
||||
}
|
||||
if (cached) {
|
||||
perChain[chain] = { ...cached, stale: true, error: reason };
|
||||
if (typeof cached.totalUsd === 'number') totalUsd += cached.totalUsd;
|
||||
} else {
|
||||
perChain[chain] = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
totalUsd: roundUsd(totalUsd) ?? 0,
|
||||
hasErrors,
|
||||
perChain: perChain as Record<ChainCode, ChainPortfolio | null>,
|
||||
};
|
||||
}
|
||||
|
||||
async function btcBalance(address: string): Promise<string> {
|
||||
const res = await fetchJson(`${BLOCKSTREAM}/address/${address}`);
|
||||
const stats = res.chain_stats;
|
||||
|
||||
Reference in New Issue
Block a user