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;

View File

@@ -146,6 +146,48 @@
}
}
},
"ChainPortfolio": {
"type": "object",
"description": "Балансе одной сети в составе portfolio. Расширяет BalanceResponse.data полями totalUsd, stale, lastUpdated, error.",
"properties": {
"chain": { "$ref": "#/components/schemas/Chain" },
"address": { "type": "string" },
"totalUsd": { "type": "number", "nullable": true, "description": "Сумма usdValue по native + всем токенам chain'а. null если все цены недоступны." },
"native": { "$ref": "#/components/schemas/FormattedAmount" },
"tokens": {
"type": "object",
"additionalProperties": { "$ref": "#/components/schemas/FormattedAmount" }
},
"stale": { "type": "boolean", "description": "true = данные из KeyDB cache (RPC chain'а упал в этом запросе)" },
"lastUpdated": { "type": "integer", "description": "Unix ms когда данные были обновлены fresh fetch'ем" },
"error": { "type": "string", "nullable": true, "description": "Причина почему stale (только если stale=true)" }
}
},
"PortfolioResponse": {
"type": "object",
"properties": {
"success": { "type": "boolean", "example": true },
"data": {
"type": "object",
"required": ["totalUsd", "hasErrors", "perChain"],
"properties": {
"totalUsd": { "type": "number", "description": "Grand sum USD по всем сетям (rounded к 8 знакам). 0 если все сети упали и нет cache." },
"hasErrors": { "type": "boolean", "description": "true если хотя бы одна сеть в stale/error состоянии" },
"perChain": {
"type": "object",
"description": "Per-chain breakdown. Ключ = chain code (ETH/BSC/BTC/TRX/SOL). Значение null если ни fresh, ни cache недоступны.",
"properties": {
"ETH": { "$ref": "#/components/schemas/ChainPortfolio", "nullable": true },
"BSC": { "$ref": "#/components/schemas/ChainPortfolio", "nullable": true },
"BTC": { "$ref": "#/components/schemas/ChainPortfolio", "nullable": true },
"TRX": { "$ref": "#/components/schemas/ChainPortfolio", "nullable": true },
"SOL": { "$ref": "#/components/schemas/ChainPortfolio", "nullable": true }
}
}
}
}
}
},
"Transaction": {
"type": "object",
"properties": {
@@ -289,6 +331,41 @@
}
},
"/wallets/portfolio": {
"get": {
"summary": "Aggregate balance по всем 5 сетям (общий баланс)",
"description": "Возвращает баланс всех 5 сетей + grand total USD в одном запросе. Параллельно дёргает `getBalance(chain, address)` для ETH/BSC/BTC/TRX/SOL. Каждая успешная сеть кэшируется в KeyDB (TTL 1 час). Если какая-то сеть упала (RPC timeout / network error) — возвращает последний кэшированный balance этой сети с пометкой `stale:true` и описанием `error`. UI всегда показывает осмысленный portfolio, не падая на 0 при transient outage.\n\n**Поведение при ошибках:**\n- 1 сеть упала + есть cache → totalUsd считается с cached + `hasErrors:true`\n- 1 сеть упала + НЕТ cache → perChain[chain]=null, остальное fresh\n- все 5 упали + нет cache → totalUsd=0, hasErrors=true, perChain[*]=null\n- 502 возвращается только при unrecoverable controller exception",
"tags": ["Wallet Ops"],
"responses": {
"200": {
"description": "Aggregate portfolio",
"content": {
"application/json": {
"schema": { "$ref": "#/components/schemas/PortfolioResponse" },
"example": {
"success": true,
"data": {
"totalUsd": 12.34,
"hasErrors": false,
"perChain": {
"ETH": { "chain":"ETH", "address":"0x9dB8Af1B...", "totalUsd":4.81, "native":{"raw":"1500000000000000000","formatted":"1.5","decimals":18,"usdPrice":3210.45,"usdValue":4.81}, "tokens":{}, "stale":false, "lastUpdated":1715600000000 },
"BSC": { "chain":"BSC", "address":"0x9dB8Af1B...", "totalUsd":2.10, "native":{"...":"..."}, "tokens":{"USDT":{"...":"..."}}, "stale":false, "lastUpdated":1715600000000 },
"BTC": { "chain":"BTC", "address":"bc1q...", "totalUsd":3.96, "native":{"...":"..."}, "stale":false, "lastUpdated":1715600000000 },
"TRX": { "chain":"TRX", "address":"T...", "totalUsd":0.49, "native":{"...":"..."}, "tokens":{"USDT":{"...":"..."}}, "stale":true, "lastUpdated":1715500000000, "error":"TronGrid timeout" },
"SOL": { "chain":"SOL", "address":"3PJC...", "totalUsd":0.98, "native":{"...":"..."}, "tokens":{}, "stale":false, "lastUpdated":1715600000000 }
}
}
}
}
}
},
"401": { "description": "Not authenticated" },
"404": { "description": "No wallets created (вызови POST /wallets/create сначала)" },
"502": { "description": "Portfolio fetch error" }
}
}
},
"/wallets/{chain}/balance": {
"get": {
"summary": "Balance for user wallet in chain (с USD-ценами)",