From 444030e424596a292e8c2fb103bc56a499565fbe Mon Sep 17 00:00:00 2001 From: ZOMBIIIIIII <120676065+Metaton241@users.noreply.github.com> Date: Thu, 28 May 2026 22:02:37 +0300 Subject: [PATCH] init449494 --- .env.example | 39 +++++ README.md | 4 + apps/api/src/lib/token-registry.ts | 14 +- apps/api/src/middleware/csrf.ts | 7 +- apps/api/src/routes/jumper-proxy.routes.ts | 168 +++++++++++++++++++++ apps/api/src/routes/tokens.routes.ts | 58 ++++--- 6 files changed, 269 insertions(+), 21 deletions(-) create mode 100644 .env.example diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..faf858a --- /dev/null +++ b/.env.example @@ -0,0 +1,39 @@ +# CryptoWallet API deploy env. +# Copy to .env on server and fill real values before docker compose up. + +# REQUIRED. API will not start without Vault AppRole. +VAULT_ADDR=https://corp.vault.elcsa.ru +VAULT_ROLE_ID= +VAULT_SECRET_ID= +VAULT_MOUNT_POINT=dev-secrets +VAULT_SECRET_PATH=database +VAULT_JWT_KID_PATH=jwt/kid +VAULT_JWT_KIDS_PREFIX=jwt/kids +VAULT_CSRF_PATH=csrf +VAULT_CRYPTO_KEY_PATH=crypto/master + +JWT_ALGORITHM=RS256 +JWT_ISSUER=bitok +JWT_AUDIENCE=elcsa + +API_PORT=3001 +LOG_LEVEL=INFO + +# Production: replace * with exact frontend origins. +CORS_ORIGINS=* +CORS_ALLOW_CREDENTIALS=false + +REDIS_HOST=keydb +REDIS_PORT=6379 +REDIS_PASSWORD= +REDIS_DB=0 + +COINGECKO_API_KEY= +RELAY_API_KEY= +TRON_API_KEY= +JUPITER_API_KEY= +JUPITER_REFERRAL_ACCOUNT= +JUPITER_FEE_BPS=70 + +# Optional outbound proxy for RPC / swap / bridge calls. +OUTBOUND_PROXY_URL= diff --git a/README.md b/README.md index b939f17..c78d481 100644 --- a/README.md +++ b/README.md @@ -37,6 +37,8 @@ scp -P 2222 -r deployserver/ server@:~/cryptowallet/ # На сервере: убедись что .env заполнен (VAULT_*, JWT_*, CORS_ORIGINS, REDIS_PASSWORD, ...) ssh server@ -p 2222 cd ~/cryptowallet/deployserver +cp .env.example .env +nano .env # VAULT_ADDR / VAULT_ROLE_ID / VAULT_SECRET_ID НЕ должны быть пустыми docker compose up -d --build docker compose logs -f api curl http://localhost:3001/api/health @@ -44,6 +46,8 @@ curl http://localhost:3001/api/health API **не делает migrations / DROP / ALTER** при старте — только INSERT/UPDATE/SELECT. Schema (если нужны новые колонки/таблицы для нового функционала) обновляется только руками: `psql -f cryptowallet-schema.sql` (script append-only — `CREATE TABLE IF NOT EXISTS` / `ALTER TABLE ADD COLUMN IF NOT EXISTS`, никаких DROP). +Если в логах есть `Vault not configured, using .env` и затем `Initial Vault refresh failed: vault_not_configured`, значит контейнер получил пустые `VAULT_ADDR`, `VAULT_ROLE_ID` или `VAULT_SECRET_ID`. Это не nginx-проблема: API падает на старте, пока AppRole не заполнен. + ## Update / Rebuild ```bash diff --git a/apps/api/src/lib/token-registry.ts b/apps/api/src/lib/token-registry.ts index 32e4e86..42bf513 100644 --- a/apps/api/src/lib/token-registry.ts +++ b/apps/api/src/lib/token-registry.ts @@ -171,7 +171,7 @@ function isBridgeable(chain: ChainCode, symbol: string): boolean { * * @param filterChain — если задан, фильтрует только этот chain * @param bridgeableOnly — если true, возвращает только tokens из `BRIDGEABLE_TOKENS` allowlist - * (used by Jumper bridge UI чтобы не показывать unsupported memecoins) + * (used by bridge/swap UI чтобы не показывать unsupported memecoins) */ export function getAllTokens(filterChain?: ChainCode, bridgeableOnly: boolean = false): TokenListEntry[] { const out: TokenListEntry[] = []; @@ -210,6 +210,18 @@ export function getAllTokens(filterChain?: ChainCode, bridgeableOnly: boolean = return out; } +/** + * Multi-chain variant для `/api/tokens?chains=ETH,BSC,...`. + * По умолчанию compact/bridgeable список, потому что endpoint используется UI dropdown'ами. + */ +export function getTokensForChains( + filterChains?: ChainCode[], + bridgeableOnly: boolean = true, +): TokenListEntry[] { + const chains = filterChains && filterChains.length > 0 ? filterChains : ALL_CHAINS_ORDERED; + return chains.flatMap((chain) => getAllTokens(chain, bridgeableOnly)); +} + export function getEvmTokens(chain: ChainCode): EvmToken[] { if (chain === 'ETH') return ETH_TOKENS; if (chain === 'BSC') return BSC_TOKENS; diff --git a/apps/api/src/middleware/csrf.ts b/apps/api/src/middleware/csrf.ts index 0d42327..952f41a 100644 --- a/apps/api/src/middleware/csrf.ts +++ b/apps/api/src/middleware/csrf.ts @@ -28,11 +28,12 @@ export function csrfMiddleware(req: Request, res: Response, next: NextFunction): return; } - // Bearer-auth (Authorization header, no access_token cookie) — CSRF не нужен. + // Bearer-auth (explicit Authorization header) — CSRF не нужен. // Browser-based CSRF атаки требуют auto-sent cookie. Если auth идёт через explicit // Authorization header, attacker не может его выставить cross-origin (CORS preflight блокирует). - // Это позволяет API-клиентам (Swagger UI, Postman, curl, мобилке) работать без CSRF. - if (!req.cookies?.access_token && req.headers.authorization) { + // Это позволяет API-клиентам и фронту с Bearer-token работать без double-submit CSRF, + // даже если браузер параллельно прислал stale access_token cookie. + if (req.headers.authorization) { next(); return; } diff --git a/apps/api/src/routes/jumper-proxy.routes.ts b/apps/api/src/routes/jumper-proxy.routes.ts index 8e7bcae..11379f2 100644 --- a/apps/api/src/routes/jumper-proxy.routes.ts +++ b/apps/api/src/routes/jumper-proxy.routes.ts @@ -20,6 +20,7 @@ import { logger } from '../lib/logger'; import { WalletModel } from '../models/wallet.model'; import type { ChainCode } from '../lib/address-validators'; import { proxiedFetch } from '../lib/outbound-proxy'; +import { getTokensForChains } from '../lib/token-registry'; const router = Router(); const LIFI_API_URL = 'https://li.quest/v1'; @@ -43,6 +44,24 @@ const JUMPER_CHAINID_TO_CHAIN: Record = { 20000000000001: 'BTC', }; +const ALLOWED_JUMPER_CHAIN_IDS = new Set(Object.keys(JUMPER_CHAINID_TO_CHAIN).map(Number)); +const JUMPER_CHAIN_BY_CODE: Partial> = Object.entries(JUMPER_CHAINID_TO_CHAIN) + .reduce((acc, [chainId, code]) => ({ ...acc, [code]: Number(chainId) }), {}); +const JUMPER_NATIVE_SENTINELS: Partial> = { + ETH: '0x0000000000000000000000000000000000000000', + BSC: '0x0000000000000000000000000000000000000000', + SOL: '11111111111111111111111111111111', + TRX: 'T9yD14Nj9j7xAB4dbGeiX9h8unkKHxuWwb', + BTC: 'bitcoin', +}; +const LOCAL_JUMPER_CHAINS = [ + { key: 'eth', chainType: 'EVM', name: 'Ethereum', coin: 'ETH', id: 1, mainnet: true }, + { key: 'bsc', chainType: 'EVM', name: 'BSC', coin: 'BNB', id: 56, mainnet: true }, + { key: 'sol', chainType: 'SVM', name: 'Solana', coin: 'SOL', id: 1151111081099710, mainnet: true }, + { key: 'trx', chainType: 'TVM', name: 'Tron', coin: 'TRX', id: 728126428, mainnet: true }, + { key: 'btc', chainType: 'UTXO', name: 'Bitcoin', coin: 'BTC', id: 20000000000001, mainnet: true }, +]; + // Whitelist: GET-paths + POST-paths. Запрещён любой другой путь (path traversal). const ALLOWED_GET_PATHS = new Set([ '/quote', // single best route @@ -194,6 +213,11 @@ async function proxyJumperRequest(req: Request, res: Response, _next: NextFuncti } try { + const filtered = filterJumperMetadata(jumperPath, text); + if (filtered) { + res.json(filtered); + return; + } res.send(text); } catch { res.json({ success: false, error: 'Jumper returned non-JSON' }); @@ -209,6 +233,150 @@ async function proxyJumperRequest(req: Request, res: Response, _next: NextFuncti } } +function filterJumperMetadata(jumperPath: string, text: string): unknown | null { + if (jumperPath !== '/chains' && jumperPath !== '/tokens' && jumperPath !== '/tools') { + return null; + } + + const parsed = JSON.parse(text); + if (jumperPath === '/chains') { + return filterChainsResponse(parsed); + } + if (jumperPath === '/tokens') { + return filterTokensResponse(parsed); + } + return filterToolsResponse(parsed); +} + +function filterChainsResponse(body: any): any { + if (!Array.isArray(body?.chains)) return body; + const upstream = body.chains.filter((chain: any) => ALLOWED_JUMPER_CHAIN_IDS.has(Number(chain?.id))); + const byId = new Map(); + for (const chain of [...upstream, ...LOCAL_JUMPER_CHAINS]) { + byId.set(Number(chain.id), chain); + } + return { + ...body, + chains: [...ALLOWED_JUMPER_CHAIN_IDS] + .map((chainId) => byId.get(chainId)) + .filter(Boolean), + }; +} + +function filterTokensResponse(body: any): any { + if (!body?.tokens || typeof body.tokens !== 'object') return body; + + const allow = buildAllowedTokenMap(); + const local = buildLocalTokenMap(); + const filteredByChain = new Map>(); + + for (const [chainId, tokens] of Object.entries(body.tokens)) { + if (!ALLOWED_JUMPER_CHAIN_IDS.has(Number(chainId)) || !Array.isArray(tokens)) continue; + const numericChainId = Number(chainId); + const allowedForChain = allow.get(numericChainId); + if (!allowedForChain) continue; + const merged = filteredByChain.get(numericChainId) ?? buildTokenMap(local.get(numericChainId) ?? []); + for (const token of tokens) { + const key = tokenKey(token); + if (allowedForChain.has(key)) { + merged.set(key, token); + } + } + filteredByChain.set(numericChainId, merged); + } + + // LiFi currently omits SOL/BTC/TRX token lists from /tokens. Add our local whitelist + // so frontend can use one metadata contract for quote-best/quote and bridge/execute. + for (const chainId of ALLOWED_JUMPER_CHAIN_IDS) { + if (!filteredByChain.has(chainId)) { + filteredByChain.set(chainId, buildTokenMap(local.get(chainId) ?? [])); + } + } + + const filteredTokens: Record = {}; + for (const chainId of ALLOWED_JUMPER_CHAIN_IDS) { + const tokens = [...(filteredByChain.get(chainId)?.values() ?? [])]; + if (tokens.length > 0) { + filteredTokens[String(chainId)] = tokens; + } + } + + return { ...body, tokens: filteredTokens }; +} + +function filterToolsResponse(body: any): any { + const filterPair = (pair: any): boolean => + ALLOWED_JUMPER_CHAIN_IDS.has(Number(pair?.fromChainId)) && + ALLOWED_JUMPER_CHAIN_IDS.has(Number(pair?.toChainId)); + + const bridges = Array.isArray(body?.bridges) + ? body.bridges + .map((bridge: any) => { + const supportedChains = Array.isArray(bridge?.supportedChains) + ? bridge.supportedChains.filter(filterPair) + : []; + return { ...bridge, supportedChains }; + }) + .filter((bridge: any) => bridge.supportedChains.length > 0) + : body?.bridges; + + const exchanges = Array.isArray(body?.exchanges) + ? body.exchanges + .map((exchange: any) => { + const supportedChains = Array.isArray(exchange?.supportedChains) + ? exchange.supportedChains.filter((chainId: unknown) => ALLOWED_JUMPER_CHAIN_IDS.has(Number(chainId))) + : []; + return { ...exchange, supportedChains }; + }) + .filter((exchange: any) => exchange.supportedChains.length > 0) + : body?.exchanges; + + return { ...body, bridges, exchanges }; +} + +function buildAllowedTokenMap(): Map> { + const map = new Map>(); + for (const [chainId, tokens] of buildLocalTokenMap()) { + map.set(chainId, new Set(tokens.map(tokenKey))); + } + return map; +} + +function buildLocalTokenMap(): Map { + const map = new Map(); + const rows = getTokensForChains(['ETH', 'BSC', 'SOL', 'BTC', 'TRX'], true); + + for (const row of rows) { + const chainId = JUMPER_CHAIN_BY_CODE[row.chain]; + if (!chainId) continue; + const address = row.contract || JUMPER_NATIVE_SENTINELS[row.chain] || ''; + if (!address) continue; + const bucket = map.get(chainId) ?? []; + bucket.push({ + chainId, + address, + symbol: row.symbol, + name: row.name, + decimals: row.decimals, + coinKey: row.symbol, + source: 'cryptowallet-whitelist', + }); + map.set(chainId, bucket); + } + + return map; +} + +function buildTokenMap(tokens: any[]): Map { + return new Map(tokens.map((token) => [tokenKey(token), token])); +} + +function tokenKey(token: any): string { + const symbol = String(token?.symbol || '').toUpperCase(); + const address = String(token?.address || '').toLowerCase(); + return `${symbol}:${address}`; +} + /** * GET /api/jumper/quote-best — bridge quote с приоритетом NearIntents. * diff --git a/apps/api/src/routes/tokens.routes.ts b/apps/api/src/routes/tokens.routes.ts index 41b242d..28860ce 100644 --- a/apps/api/src/routes/tokens.routes.ts +++ b/apps/api/src/routes/tokens.routes.ts @@ -1,41 +1,65 @@ /** - * GET /api/tokens — реестр всех известных активов всех 5 сетей + native. + * GET /api/tokens — compact allowlist активов для bridge/swap UI. * * Read-only. Источник — `lib/token-registry.ts`. Никаких RPC calls, * никаких user-specific данных — только статический list контрактов с symbol + name. * * Optional query params: - * ?chain=ETH|BSC|BTC|TRX|SOL — filter одной сетью. - * ?bridgeable=true — возвращает только tokens которые реально bridgeable - * через Jumper/NearIntents (без SOL memes, BSC wrapped, и т.п.). - * Используется UI dropdowns в Jumper bridge section. + * ?chain=ETH|BSC|BTC|TRX|SOL — filter одной сетью. + * ?chains=ETH,BSC,BTC,TRX,SOL — filter нескольких сетей. + * ?includeUnsupported=true — debug/full registry mode; default = только usable tokens. */ import { Router, Request, Response } from 'express'; -import { getAllTokens } from '../lib/token-registry'; +import { getTokensForChains } from '../lib/token-registry'; import { ALL_CHAINS } from '../services/wallet-generator.service'; import type { ChainCode } from '../lib/address-validators'; const router = Router(); const ALLOWED = new Set(ALL_CHAINS); +const ALLOWED_LABEL = 'ETH, BSC, BTC, TRX, SOL'; router.get('/', (req: Request, res: Response) => { + const parseChain = (raw: string): ChainCode | null => { + const upper = raw.trim().toUpperCase(); + if (!upper) return null; + return ALLOWED.has(upper as ChainCode) ? (upper as ChainCode) : null; + }; + + const requested = new Set(); + const addChain = (raw: unknown): string | null => { + const chain = parseChain(String(raw)); + if (!chain) return String(raw); + requested.add(chain); + return null; + }; + const chainParam = req.query.chain; - let filterChain: ChainCode | undefined; if (chainParam !== undefined && chainParam !== null && chainParam !== '') { - const upper = String(chainParam).toUpperCase(); - if (!ALLOWED.has(upper as ChainCode)) { - res.status(400).json({ - success: false, - error: `Invalid chain "${chainParam}" (allowed: ETH, BSC, BTC, TRX, SOL)`, - }); + const invalid = addChain(Array.isArray(chainParam) ? chainParam[0] : chainParam); + if (invalid) { + res.status(400).json({ success: false, error: `Invalid chain "${invalid}" (allowed: ${ALLOWED_LABEL})` }); return; } - filterChain = upper as ChainCode; } - // ?bridgeable=true → filter только bridgeable tokens - const bridgeableOnly = String(req.query.bridgeable || '').toLowerCase() === 'true'; - const data = getAllTokens(filterChain, bridgeableOnly); + + const chainsParam = req.query.chains; + if (chainsParam !== undefined && chainsParam !== null && chainsParam !== '') { + const rawValues = Array.isArray(chainsParam) ? chainsParam : [chainsParam]; + for (const raw of rawValues.flatMap((value) => String(value).split(','))) { + if (!raw.trim()) continue; + const invalid = addChain(raw); + if (invalid) { + res.status(400).json({ success: false, error: `Invalid chain "${invalid}" (allowed: ${ALLOWED_LABEL})` }); + return; + } + } + } + + // Default = compact UI whitelist. Full registry only by explicit debug opt-in. + const includeUnsupported = String(req.query.includeUnsupported || '').toLowerCase() === 'true' || + String(req.query.bridgeable || '').toLowerCase() === 'false'; + const data = getTokensForChains([...requested], !includeUnsupported); res.json({ success: true, data }); });