initvglidrbtgrthijl;
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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-ценами)",
|
||||
|
||||
Reference in New Issue
Block a user