From f6774243b2b44170f94cd177f6188c2f3ff98aba Mon Sep 17 00:00:00 2001 From: ZOMBIIIIIII <120676065+Metaton241@users.noreply.github.com> Date: Thu, 14 May 2026 16:39:56 +0300 Subject: [PATCH] initvglidrbtgrthijl; --- .env.example | 8 +- apps/api/src/app.ts | 16 ++- apps/api/src/config/env.ts | 21 +-- apps/api/src/controllers/wallet.controller.ts | 25 +++- apps/api/src/routes/wallet.routes.ts | 3 + apps/api/src/services/wallet-ops.service.ts | 121 ++++++++++++++++++ apps/api/swagger.json | 77 +++++++++++ 7 files changed, 258 insertions(+), 13 deletions(-) diff --git a/.env.example b/.env.example index fbf7017..dbe4ff6 100644 --- a/.env.example +++ b/.env.example @@ -35,8 +35,12 @@ REDIS_PASSWORD= REDIS_DB=0 # ── CORS ──────────────────────────────────────────────────────────── -# Comma-separated list of allowed origins. ПУСТО = no cross-origin. -# Никогда не используй wildcard * +# Comma-separated list of allowed origins, OR "*" для wildcard (dev/staging). +# ПУСТО = no cross-origin (fail-secure). +# Wildcard incompatible с CORS_ALLOW_CREDENTIALS=true (browser spec — credentials force=false). +# Production: явный whitelist для security (XSS на любом сайте не сможет дёрнуть API). +# Whitelist: CORS_ORIGINS=https://app.example.com,https://www.example.com +# Wildcard: CORS_ORIGINS=* CORS_ORIGINS= CORS_ALLOW_CREDENTIALS=true diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts index 1576103..680e3b0 100644 --- a/apps/api/src/app.ts +++ b/apps/api/src/app.ts @@ -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 diff --git a/apps/api/src/config/env.ts b/apps/api/src/config/env.ts index 67042d0..414d6ae 100644 --- a/apps/api/src/config/env.ts +++ b/apps/api/src/config/env.ts @@ -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', diff --git a/apps/api/src/controllers/wallet.controller.ts b/apps/api/src/controllers/wallet.controller.ts index 8f958be..9b0b879 100644 --- a/apps/api/src/controllers/wallet.controller.ts +++ b/apps/api/src/controllers/wallet.controller.ts @@ -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 = {}; + 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 */ diff --git a/apps/api/src/routes/wallet.routes.ts b/apps/api/src/routes/wallet.routes.ts index efce4c8..1800cc3 100644 --- a/apps/api/src/routes/wallet.routes.ts +++ b/apps/api/src/routes/wallet.routes.ts @@ -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); diff --git a/apps/api/src/services/wallet-ops.service.ts b/apps/api/src/services/wallet-ops.service.ts index a6d4339..e5ae2db 100644 --- a/apps/api/src/services/wallet-ops.service.ts +++ b/apps/api/src/services/wallet-ops.service.ts @@ -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; +} + +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, +): Promise { + 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 | null = null; + try { redis = getRedis(); } catch { redis = null; } + + const perChain: Record = {}; + 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, + }; +} + async function btcBalance(address: string): Promise { const res = await fetchJson(`${BLOCKSTREAM}/address/${address}`); const stats = res.chain_stats; diff --git a/apps/api/swagger.json b/apps/api/swagger.json index f1c7f89..fd7eb4a 100644 --- a/apps/api/swagger.json +++ b/apps/api/swagger.json @@ -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-ценами)",