initvglidrbtgrthijl;

This commit is contained in:
ZOMBIIIIIII
2026-05-14 16:39:56 +03:00
parent 11ee5a2c7f
commit f6774243b2
7 changed files with 258 additions and 13 deletions

View File

@@ -25,10 +25,22 @@ const app = express();
app.set('trust proxy', 1);
app.use(helmet());
// CORS — поддерживаем 3 режима:
// 1. wildcard ['*'] — любой origin (для dev/staging); credentials force=false (browser spec)
// 2. whitelist [a, b, c] — только эти origins
// 3. пустой массив — все cross-origin blocked (fail-secure default)
const corsOrigins = env.cors.origins;
const corsIsWildcard = corsOrigins.length === 1 && corsOrigins[0] === '*';
if (corsIsWildcard) {
// eslint-disable-next-line no-console
console.warn('[CORS] WILDCARD enabled (CORS_ORIGINS=*) — any origin can call API. Use only for dev/staging. Production: use explicit whitelist.');
}
app.use(
cors({
origin: env.cors.origins.length > 0 ? env.cors.origins : false,
credentials: env.cors.allowCredentials,
origin: corsIsWildcard ? '*' : (corsOrigins.length > 0 ? corsOrigins : false),
// Wildcard incompatible с credentials per browser spec — force false при wildcard.
credentials: corsIsWildcard ? false : env.cors.allowCredentials,
}),
);
app.use(express.json({ limit: '64kb' })); // защита от больших payload-DoS

View File

@@ -34,20 +34,25 @@ export let env = {
cryptoKeyPath: p.VAULT_CRYPTO_KEY_PATH || 'crypto/master',
},
cors: {
// Каждый деплой явно указывает CORS_ORIGINS, иначе пустой массив = нет cross-origin.
// Validate каждый origin через URL parse — отклоняем мусор. Default https-only для prod-safety.
origins: (p.CORS_ORIGINS || '')
.split(',')
.map((o) => o.trim())
.filter(Boolean)
.filter((o) => {
// CORS_ORIGINS:
// - comma-separated list of origins → whitelist (recommended for prod)
// - "*" → wildcard, любой origin принят (для dev/staging)
// - "" → cross-origin blocked (fail-secure default)
// Wildcard incompatible с CORS_ALLOW_CREDENTIALS=true (browser spec).
origins: (() => {
const raw = (p.CORS_ORIGINS || '').split(',').map((o) => o.trim()).filter(Boolean);
// Wildcard sentinel — единственное значение `*` активирует wildcard mode.
if (raw.length === 1 && raw[0] === '*') return ['*'];
// Иначе строгая URL-валидация каждого origin'а.
return raw.filter((o) => {
try {
const u = new URL(o);
return u.protocol === 'https:' || u.protocol === 'http:';
} catch {
return false;
}
}),
});
})(),
// Default = false (fail-secure). Чтобы включить credentials cross-origin —
// ОБЯЗАТЕЛЬНО явный CORS_ALLOW_CREDENTIALS=true.
allowCredentials: p.CORS_ALLOW_CREDENTIALS === 'true',

View File

@@ -2,7 +2,7 @@ import { Request, Response } from 'express';
import { db } from '../config/database';
import { WalletModel } from '../models/wallet.model';
import { UserModel } from '../models/user.model';
import { getBalance, getTransactions } from '../services/wallet-ops.service';
import { getBalance, getTransactions, getPortfolio as getPortfolioService } from '../services/wallet-ops.service';
import { isValidAddress, isValidAmount, ChainCode } from '../lib/address-validators';
import { generateMnemonic, deriveAllAddresses, ALL_CHAINS } from '../services/wallet-generator.service';
import { encryptMnemonic, decryptMnemonic, isCryptoReady } from '../services/crypto.service';
@@ -233,6 +233,29 @@ export const WalletController = {
}
},
/**
* GET /api/wallets/portfolio — общий баланс всех 5 сетей.
* Возвращает grand totalUsd + per-chain breakdown.
* При сбое RPC отдельной сети — возвращает stale snapshot из KeyDB (TTL 1 час).
*/
async getPortfolio(req: Request, res: Response) {
const userId = req.auth!.userId;
try {
const wallets = await WalletModel.findByUserId(userId);
if (wallets.length === 0) {
res.status(404).json({ success: false, error: 'No wallets created — POST /wallets/create first' });
return;
}
const addresses: Record<string, string> = {};
for (const w of wallets) addresses[w.chain] = w.address;
const data = await getPortfolioService(userId, addresses as any);
res.json({ success: true, data });
} catch (err: any) {
logger.error(`getPortfolio failed for user ${userId}: ${err.stack || err.message}`);
res.status(502).json({ success: false, error: 'Portfolio fetch error' });
}
},
/**
* GET /api/wallets/:chain/balance
*/

View File

@@ -7,6 +7,9 @@ router.post('/create', WalletController.createWallet);
router.get('/', WalletController.getWallets);
router.post('/mnemonic/reveal', WalletController.revealMnemonic);
// IMPORTANT: /portfolio ДОЛЖЕН быть ПЕРЕД /:chain/... иначе express матчит chain='portfolio'.
router.get('/portfolio', WalletController.getPortfolio);
router.get('/:chain/balance', WalletController.getChainBalance);
router.get('/:chain/transactions', WalletController.getChainTransactions);
router.get('/:chain/gas-suggestions', WalletController.getGasSuggestions);

View File

@@ -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;