initvglidrbtgrthijl;
This commit is contained in:
@@ -35,8 +35,12 @@ REDIS_PASSWORD=
|
|||||||
REDIS_DB=0
|
REDIS_DB=0
|
||||||
|
|
||||||
# ── CORS ────────────────────────────────────────────────────────────
|
# ── CORS ────────────────────────────────────────────────────────────
|
||||||
# Comma-separated list of allowed origins. ПУСТО = no cross-origin.
|
# Comma-separated list of allowed origins, OR "*" для wildcard (dev/staging).
|
||||||
# Никогда не используй wildcard *
|
# ПУСТО = 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_ORIGINS=
|
||||||
CORS_ALLOW_CREDENTIALS=true
|
CORS_ALLOW_CREDENTIALS=true
|
||||||
|
|
||||||
|
|||||||
@@ -25,10 +25,22 @@ const app = express();
|
|||||||
app.set('trust proxy', 1);
|
app.set('trust proxy', 1);
|
||||||
|
|
||||||
app.use(helmet());
|
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(
|
app.use(
|
||||||
cors({
|
cors({
|
||||||
origin: env.cors.origins.length > 0 ? env.cors.origins : false,
|
origin: corsIsWildcard ? '*' : (corsOrigins.length > 0 ? corsOrigins : false),
|
||||||
credentials: env.cors.allowCredentials,
|
// Wildcard incompatible с credentials per browser spec — force false при wildcard.
|
||||||
|
credentials: corsIsWildcard ? false : env.cors.allowCredentials,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
app.use(express.json({ limit: '64kb' })); // защита от больших payload-DoS
|
app.use(express.json({ limit: '64kb' })); // защита от больших payload-DoS
|
||||||
|
|||||||
@@ -34,20 +34,25 @@ export let env = {
|
|||||||
cryptoKeyPath: p.VAULT_CRYPTO_KEY_PATH || 'crypto/master',
|
cryptoKeyPath: p.VAULT_CRYPTO_KEY_PATH || 'crypto/master',
|
||||||
},
|
},
|
||||||
cors: {
|
cors: {
|
||||||
// Каждый деплой явно указывает CORS_ORIGINS, иначе пустой массив = нет cross-origin.
|
// CORS_ORIGINS:
|
||||||
// Validate каждый origin через URL parse — отклоняем мусор. Default https-only для prod-safety.
|
// - comma-separated list of origins → whitelist (recommended for prod)
|
||||||
origins: (p.CORS_ORIGINS || '')
|
// - "*" → wildcard, любой origin принят (для dev/staging)
|
||||||
.split(',')
|
// - "" → cross-origin blocked (fail-secure default)
|
||||||
.map((o) => o.trim())
|
// Wildcard incompatible с CORS_ALLOW_CREDENTIALS=true (browser spec).
|
||||||
.filter(Boolean)
|
origins: (() => {
|
||||||
.filter((o) => {
|
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 {
|
try {
|
||||||
const u = new URL(o);
|
const u = new URL(o);
|
||||||
return u.protocol === 'https:' || u.protocol === 'http:';
|
return u.protocol === 'https:' || u.protocol === 'http:';
|
||||||
} catch {
|
} catch {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}),
|
});
|
||||||
|
})(),
|
||||||
// Default = false (fail-secure). Чтобы включить credentials cross-origin —
|
// Default = false (fail-secure). Чтобы включить credentials cross-origin —
|
||||||
// ОБЯЗАТЕЛЬНО явный CORS_ALLOW_CREDENTIALS=true.
|
// ОБЯЗАТЕЛЬНО явный CORS_ALLOW_CREDENTIALS=true.
|
||||||
allowCredentials: p.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 { db } from '../config/database';
|
||||||
import { WalletModel } from '../models/wallet.model';
|
import { WalletModel } from '../models/wallet.model';
|
||||||
import { UserModel } from '../models/user.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 { isValidAddress, isValidAmount, ChainCode } from '../lib/address-validators';
|
||||||
import { generateMnemonic, deriveAllAddresses, ALL_CHAINS } from '../services/wallet-generator.service';
|
import { generateMnemonic, deriveAllAddresses, ALL_CHAINS } from '../services/wallet-generator.service';
|
||||||
import { encryptMnemonic, decryptMnemonic, isCryptoReady } from '../services/crypto.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
|
* GET /api/wallets/:chain/balance
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -7,6 +7,9 @@ router.post('/create', WalletController.createWallet);
|
|||||||
router.get('/', WalletController.getWallets);
|
router.get('/', WalletController.getWallets);
|
||||||
router.post('/mnemonic/reveal', WalletController.revealMnemonic);
|
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/balance', WalletController.getChainBalance);
|
||||||
router.get('/:chain/transactions', WalletController.getChainTransactions);
|
router.get('/:chain/transactions', WalletController.getChainTransactions);
|
||||||
router.get('/:chain/gas-suggestions', WalletController.getGasSuggestions);
|
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 { getEvmTokens, getTrxTokens, getSolTokens } from '../lib/token-registry';
|
||||||
import { getPricesBySymbols } from './price-oracle.service';
|
import { getPricesBySymbols } from './price-oracle.service';
|
||||||
import { logger } from '../lib/logger';
|
import { logger } from '../lib/logger';
|
||||||
|
import { getRedis } from '../config/redis';
|
||||||
|
|
||||||
export type ChainCode = 'ETH' | 'BTC' | 'SOL' | 'TRX' | 'BSC';
|
export type ChainCode = 'ETH' | 'BTC' | 'SOL' | 'TRX' | 'BSC';
|
||||||
|
|
||||||
@@ -211,6 +212,126 @@ export async function getBalance(chain: ChainCode, address: string): Promise<Bal
|
|||||||
return result;
|
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> {
|
async function btcBalance(address: string): Promise<string> {
|
||||||
const res = await fetchJson(`${BLOCKSTREAM}/address/${address}`);
|
const res = await fetchJson(`${BLOCKSTREAM}/address/${address}`);
|
||||||
const stats = res.chain_stats;
|
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": {
|
"Transaction": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"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": {
|
"/wallets/{chain}/balance": {
|
||||||
"get": {
|
"get": {
|
||||||
"summary": "Balance for user wallet in chain (с USD-ценами)",
|
"summary": "Balance for user wallet in chain (с USD-ценами)",
|
||||||
|
|||||||
Reference in New Issue
Block a user