diff --git a/.env.example b/.env.example index 3c09f46..ce94d61 100644 --- a/.env.example +++ b/.env.example @@ -51,6 +51,13 @@ JUPITER_FEE_BPS=70 ETHERSCAN_API_KEY= BSCSCAN_API_KEY= +# ── Price oracle (optional) ───────────────────────────────────────── +# CoinGecko Demo API key. Без ключа работает free tier (~10-30 req/min). +# Если задан → передаётся через header `x-cg-demo-api-key`. +# Используется в /api/wallets/{chain}/balance (для usdPrice/usdValue) +# и /api/prices?symbols=... KeyDB cache: 5 минут. +COINGECKO_API_KEY= + # ── DB fallback (если Vault недоступен при старте) ───────────────── DB_HOST= DB_PORT=5432 diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts index 738afcc..1576103 100644 --- a/apps/api/src/app.ts +++ b/apps/api/src/app.ts @@ -17,6 +17,7 @@ import solSwapProxyRoutes from './routes/sol-swap-proxy.routes'; import tronSwapProxyRoutes from './routes/tron-swap-proxy.routes'; import btcProxyRoutes from './routes/btc-proxy.routes'; import bscSwapProxyRoutes from './routes/bsc-swap-proxy.routes'; +import pricesRoutes from './routes/prices.routes'; const app = express(); @@ -97,6 +98,9 @@ app.use('/api/tron/swap', ...protect, mutateLimiter, tronSwapProxyRoutes); app.use('/api/btc', ...protect, mutateLimiter, btcProxyRoutes); app.use('/api/bsc/swap', ...protect, mutateLimiter, bscSwapProxyRoutes); +// USD-цены (CoinGecko + KeyDB cache). GET-only, auth required, max 50 symbols. +app.use('/api/prices', ...protect, mutateLimiter, pricesRoutes); + // 404 для всего что не сматчилось выше — единый JSON-ответ, не express default text app.use((_req, res) => { res.status(404).json({ success: false, error: 'Not found' }); diff --git a/apps/api/src/controllers/prices.controller.ts b/apps/api/src/controllers/prices.controller.ts new file mode 100644 index 0000000..4a05167 --- /dev/null +++ b/apps/api/src/controllers/prices.controller.ts @@ -0,0 +1,138 @@ +/** + * GET /api/prices — USD prices for selected token symbols. + * + * Security: + * S1 — whitelist через `getCoingeckoId`. Любой symbol вне registry → 400. + * S2 — лимит max 50 (symbol, chain) пар. Иначе → 400. + * S5 — общий 502 при failure, без stack trace. + * S7 — auth provided by router middleware. + */ +import { Request, Response } from 'express'; +import { getCoingeckoId } from '../lib/token-registry'; +import { ALL_CHAINS } from '../services/wallet-generator.service'; +import { getPricesBySymbols } from '../services/price-oracle.service'; +import type { ChainCode } from '../lib/address-validators'; +import { logger } from '../lib/logger'; + +const MAX_SYMBOLS_PER_REQUEST = 50; +const ALLOWED_CHAINS = new Set(ALL_CHAINS); +const SYMBOL_RE = /^[A-Z0-9]{1,16}$/; + +function isChain(v: unknown): v is ChainCode { + return typeof v === 'string' && ALLOWED_CHAINS.has(v as ChainCode); +} + +export const PricesController = { + /** + * GET /api/prices?symbols=BTC,ETH,USDT&chain=ETH + * + * Params: + * - symbols: comma-separated list, max 50. Каждый symbol должен быть в whitelist. + * - chain (опционально): chain для disambiguation (USDT на ETH vs USDT на BSC). + * Если не указан — для каждого symbol fallback порядок: ETH → BSC → SOL → TRX → BTC. + * Native symbol (BTC/ETH/...) всегда matches its chain. + * + * Response 200: + * { success: true, data: { "BTC": { "usd": 67432.12 }, "ETH": { "usd": 3210.45 }, "FOO": { "usd": null } } } + */ + async getPrices(req: Request, res: Response) { + try { + const rawSymbols = String(req.query.symbols || '').trim(); + if (!rawSymbols) { + res.status(400).json({ success: false, error: 'symbols query param is required (csv)' }); + return; + } + + const requestedChain = req.query.chain ? String(req.query.chain).toUpperCase() : null; + if (requestedChain && !isChain(requestedChain)) { + res.status(400).json({ success: false, error: 'Invalid chain parameter' }); + return; + } + + const symbols = rawSymbols + .split(',') + .map((s) => s.trim().toUpperCase()) + .filter((s) => s.length > 0); + + if (symbols.length === 0) { + res.status(400).json({ success: false, error: 'symbols list is empty' }); + return; + } + if (symbols.length > MAX_SYMBOLS_PER_REQUEST) { + res.status(400).json({ + success: false, + error: `Too many symbols (max ${MAX_SYMBOLS_PER_REQUEST})`, + }); + return; + } + + // Strict symbol shape (S1 belt-and-suspenders). + for (const s of symbols) { + if (!SYMBOL_RE.test(s)) { + res.status(400).json({ success: false, error: `Invalid symbol: ${s}` }); + return; + } + } + + // Build (chain, symbol) pairs. + // Fallback resolution order при отсутствии явного chain: + // native symbol == chain code → that chain; + // иначе пробуем ETH, BSC, SOL, TRX, BTC по очереди. + const fallbackChains: ChainCode[] = ['ETH', 'BSC', 'SOL', 'TRX', 'BTC']; + const pairs: { chain: ChainCode; symbol: string; key: string }[] = []; + + for (const sym of symbols) { + if (requestedChain) { + pairs.push({ chain: requestedChain as ChainCode, symbol: sym, key: sym }); + continue; + } + let resolvedChain: ChainCode | null = null; + if (ALLOWED_CHAINS.has(sym as ChainCode)) { + resolvedChain = sym as ChainCode; + } else { + for (const c of fallbackChains) { + if (getCoingeckoId(c, sym)) { + resolvedChain = c; + break; + } + } + } + if (!resolvedChain) { + // Symbol не находится ни в одной chain → 400 (S1: whitelist enforcement). + res.status(400).json({ success: false, error: `Unknown symbol: ${sym}` }); + return; + } + pairs.push({ chain: resolvedChain, symbol: sym, key: sym }); + } + + // Если явный chain задан — повторная проверка whitelist для каждого symbol + // (native symbol для chain'а тоже разрешён). + if (requestedChain) { + for (const p of pairs) { + if (!getCoingeckoId(p.chain, p.symbol)) { + res.status(400).json({ + success: false, + error: `Unknown symbol ${p.symbol} for chain ${p.chain}`, + }); + return; + } + } + } + + const prices = await getPricesBySymbols( + pairs.map((p) => ({ chain: p.chain, symbol: p.symbol })), + ); + + const data: Record = {}; + for (const p of pairs) { + const lookupKey = `${p.chain}:${p.symbol}`; + data[p.key] = { usd: prices.get(lookupKey) ?? null }; + } + + res.json({ success: true, data }); + } catch (err: any) { + logger.error(`getPrices failed: ${err?.stack || err?.message || 'unknown'}`); + res.status(502).json({ success: false, error: 'Upstream price oracle error' }); + } + }, +}; diff --git a/apps/api/src/lib/token-registry.ts b/apps/api/src/lib/token-registry.ts index f67f004..f50517e 100644 --- a/apps/api/src/lib/token-registry.ts +++ b/apps/api/src/lib/token-registry.ts @@ -12,57 +12,72 @@ export interface EvmToken { symbol: string; contractAddress: string; decimals: number; + coingeckoId?: string; } export interface TrxToken { symbol: string; contractAddress: string; // T...base58 decimals: number; + coingeckoId?: string; } export interface SolToken { symbol: string; mint: string; // SPL mint pubkey (base58) decimals: number; + coingeckoId?: string; } +/** + * CoinGecko coin IDs для native монет каждой chain. + * Используется в `price-oracle.service.ts` для USD-цен в `/balance`. + */ +export const NATIVE_COINGECKO_IDS: Record = { + BTC: 'bitcoin', + ETH: 'ethereum', + BSC: 'binancecoin', + TRX: 'tron', + SOL: 'solana', +}; + export const ETH_TOKENS: EvmToken[] = [ - { symbol: 'USDT', contractAddress: '0xdAC17F958D2ee523a2206206994597C13D831ec7', decimals: 6 }, - { symbol: 'USDC', contractAddress: '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48', decimals: 6 }, - { symbol: 'DAI', contractAddress: '0x6B175474E89094C44Da98b954EedeAC495271d0F', decimals: 18 }, - { symbol: 'WBTC', contractAddress: '0x2260FAC5E5542a773Aa44fBCfeDf7C193bc2C599', decimals: 8 }, - { symbol: 'LINK', contractAddress: '0x514910771AF9Ca656af840dff83E8264EcF986CA', decimals: 18 }, - { symbol: 'UNI', contractAddress: '0x1f9840a85d5aF5bf1D1762F925BDADdC4201F984', decimals: 18 }, + { symbol: 'USDT', contractAddress: '0xdAC17F958D2ee523a2206206994597C13D831ec7', decimals: 6, coingeckoId: 'tether' }, + { symbol: 'USDC', contractAddress: '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48', decimals: 6, coingeckoId: 'usd-coin' }, + { symbol: 'DAI', contractAddress: '0x6B175474E89094C44Da98b954EedeAC495271d0F', decimals: 18, coingeckoId: 'dai' }, + { symbol: 'WBTC', contractAddress: '0x2260FAC5E5542a773Aa44fBCfeDf7C193bc2C599', decimals: 8, coingeckoId: 'wrapped-bitcoin' }, + { symbol: 'LINK', contractAddress: '0x514910771AF9Ca656af840dff83E8264EcF986CA', decimals: 18, coingeckoId: 'chainlink' }, + { symbol: 'UNI', contractAddress: '0x1f9840a85d5aF5bf1D1762F925BDADdC4201F984', decimals: 18, coingeckoId: 'uniswap' }, ]; export const BSC_TOKENS: EvmToken[] = [ - { symbol: 'USDT', contractAddress: '0x55d398326f99059fF775485246999027B3197955', decimals: 18 }, - { symbol: 'USDC', contractAddress: '0x8AC76a51cc950d9822D68b83fE1Ad97B32Cd580d', decimals: 18 }, - { symbol: 'DOGE', contractAddress: '0xbA2aE424d960c26247Dd6c32edC70B295c744C43', decimals: 8 }, - { symbol: 'WBNB', contractAddress: '0xbb4CdB9CBd36B01bD1cBaEBF2De08d9173bc095c', decimals: 18 }, - { symbol: 'BUSD', contractAddress: '0xe9e7CEA3DedcA5984780Bafc599bD69ADd087D56', decimals: 18 }, + { symbol: 'USDT', contractAddress: '0x55d398326f99059fF775485246999027B3197955', decimals: 18, coingeckoId: 'tether' }, + { symbol: 'USDC', contractAddress: '0x8AC76a51cc950d9822D68b83fE1Ad97B32Cd580d', decimals: 18, coingeckoId: 'usd-coin' }, + { symbol: 'DOGE', contractAddress: '0xbA2aE424d960c26247Dd6c32edC70B295c744C43', decimals: 8, coingeckoId: 'dogecoin' }, + { symbol: 'WBNB', contractAddress: '0xbb4CdB9CBd36B01bD1cBaEBF2De08d9173bc095c', decimals: 18, coingeckoId: 'wbnb' }, + { symbol: 'BUSD', contractAddress: '0xe9e7CEA3DedcA5984780Bafc599bD69ADd087D56', decimals: 18, coingeckoId: 'binance-usd' }, ]; export const TRX_TOKENS: TrxToken[] = [ - { symbol: 'USDT', contractAddress: 'TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t', decimals: 6 }, - { symbol: 'USDC', contractAddress: 'TEkxiTehnzSmSe2XqrBj4w32RUN966rdz8', decimals: 6 }, + { symbol: 'USDT', contractAddress: 'TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t', decimals: 6, coingeckoId: 'tether' }, + { symbol: 'USDC', contractAddress: 'TEkxiTehnzSmSe2XqrBj4w32RUN966rdz8', decimals: 6, coingeckoId: 'usd-coin' }, ]; export const SOL_TOKENS: SolToken[] = [ - { symbol: 'USDT', mint: 'Es9vMFrzaCERmJfrF4H2FYD4KCoNkY11McCe8BenwNYB', decimals: 6 }, - { symbol: 'USDC', mint: 'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v', decimals: 6 }, - { symbol: 'PUMP', mint: 'pumpCmXqMfrsAkQ5r49WcJnRayYRqmXz6ae8H7H9Dfn', decimals: 6 }, - { symbol: 'JUP', mint: 'JUPyiwrYJFskUPiHa7hkeR8VUtAeFoSYbKedZNsDvCN', decimals: 6 }, - { symbol: 'WIF', mint: 'EKpQGSJtjMFqKZ9KQanSqYXRcF8fBopzLHYxdM65zcjm', decimals: 6 }, - { symbol: 'POPCAT', mint: '7GCihgDB8fe6KNjn2MYtkzZcRjQy3t9GHdC8uHYmW2hr', decimals: 9 }, - { symbol: 'TRUMP', mint: '6p6xgHyF7AeE6TZkSmFsko444wqoP15icUSqi2jfGiPN', decimals: 6 }, - { symbol: 'PYTH', mint: 'HZ1JovNiVvGrGNiiYvEozEVgZ58xaU3RKwX8eACQBCt3', decimals: 6 }, - { symbol: 'JTO', mint: 'jtojtomepa8beP8AuQc6eXt5FriJwfFMwQx2v2f9mCL', decimals: 9 }, - { symbol: 'W', mint: '85VBFQZC9TZkfaptBWjvUw7YbZjy52A6mjtPGjstQAmQ', decimals: 6 }, - { symbol: 'BONK', mint: 'DezXAZ8z7PnrnRJjz3wXBoRgixCa6xjnB7YaB1pPB263', decimals: 5 }, - { symbol: 'ORCA', mint: 'orcaEKTdK7LKz57vaAYr9QeNsVEPfiu6QeMU1kektZE', decimals: 6 }, - { symbol: 'PENGU', mint: '2zMMhcVQEXDtdE6vsFS7S7D5oUodfJHE8vd1gnBouauv', decimals: 6 }, - { symbol: 'RAY', mint: '4k3Dyjzvzp8eMZWUXbBCjEvwSkkk59S5iCNLY3QrkX6R', decimals: 6 }, + { symbol: 'USDT', mint: 'Es9vMFrzaCERmJfrF4H2FYD4KCoNkY11McCe8BenwNYB', decimals: 6, coingeckoId: 'tether' }, + { symbol: 'USDC', mint: 'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v', decimals: 6, coingeckoId: 'usd-coin' }, + { symbol: 'PUMP', mint: 'pumpCmXqMfrsAkQ5r49WcJnRayYRqmXz6ae8H7H9Dfn', decimals: 6, coingeckoId: 'pump-fun' }, + { symbol: 'JUP', mint: 'JUPyiwrYJFskUPiHa7hkeR8VUtAeFoSYbKedZNsDvCN', decimals: 6, coingeckoId: 'jupiter-exchange-solana' }, + { symbol: 'WIF', mint: 'EKpQGSJtjMFqKZ9KQanSqYXRcF8fBopzLHYxdM65zcjm', decimals: 6, coingeckoId: 'dogwifcoin' }, + { symbol: 'POPCAT', mint: '7GCihgDB8fe6KNjn2MYtkzZcRjQy3t9GHdC8uHYmW2hr', decimals: 9, coingeckoId: 'popcat' }, + { symbol: 'TRUMP', mint: '6p6xgHyF7AeE6TZkSmFsko444wqoP15icUSqi2jfGiPN', decimals: 6, coingeckoId: 'official-trump' }, + { symbol: 'PYTH', mint: 'HZ1JovNiVvGrGNiiYvEozEVgZ58xaU3RKwX8eACQBCt3', decimals: 6, coingeckoId: 'pyth-network' }, + { symbol: 'JTO', mint: 'jtojtomepa8beP8AuQc6eXt5FriJwfFMwQx2v2f9mCL', decimals: 9, coingeckoId: 'jito-governance-token' }, + { symbol: 'W', mint: '85VBFQZC9TZkfaptBWjvUw7YbZjy52A6mjtPGjstQAmQ', decimals: 6, coingeckoId: 'wormhole' }, + { symbol: 'BONK', mint: 'DezXAZ8z7PnrnRJjz3wXBoRgixCa6xjnB7YaB1pPB263', decimals: 5, coingeckoId: 'bonk' }, + { symbol: 'ORCA', mint: 'orcaEKTdK7LKz57vaAYr9QeNsVEPfiu6QeMU1kektZE', decimals: 6, coingeckoId: 'orca' }, + { symbol: 'PENGU', mint: '2zMMhcVQEXDtdE6vsFS7S7D5oUodfJHE8vd1gnBouauv', decimals: 6, coingeckoId: 'pudgy-penguins' }, + { symbol: 'RAY', mint: '4k3Dyjzvzp8eMZWUXbBCjEvwSkkk59S5iCNLY3QrkX6R', decimals: 6, coingeckoId: 'raydium' }, ]; export function getEvmTokens(chain: ChainCode): EvmToken[] { @@ -106,3 +121,41 @@ export function getTokenInfo(chain: ChainCode, symbol: string): { address: strin } return null; } + +/** + * Resolves the CoinGecko coin id for a given (chain, symbol) pair. + * + * Если `symbol` совпадает с самим именем chain (BTC/ETH/BSC/TRX/SOL) — возвращает + * native id (`NATIVE_COINGECKO_IDS[chain]`). + * В остальных случаях ищет токен в реестре сети и возвращает его `coingeckoId`. + * + * Возвращает `null` если: + * - chain неизвестен; + * - symbol не найден в реестре сети; + * - токен найден, но `coingeckoId` для него не задан. + * + * Используется исключительно как whitelist для price oracle (см. S1 в плане): + * никакой свободный user-input не попадает в CoinGecko URL. + */ +export function getCoingeckoId(chain: ChainCode, symbol: string): string | null { + if (!chain) return null; + const upper = String(symbol || '').toUpperCase(); + if (!upper) return null; + + // Native — symbol === chain code (BTC, ETH, ...). + if (upper === chain) return NATIVE_COINGECKO_IDS[chain] ?? null; + + if (chain === 'ETH' || chain === 'BSC') { + const t = getEvmTokens(chain).find((x) => x.symbol.toUpperCase() === upper); + return t?.coingeckoId ?? null; + } + if (chain === 'TRX') { + const t = TRX_TOKENS.find((x) => x.symbol.toUpperCase() === upper); + return t?.coingeckoId ?? null; + } + if (chain === 'SOL') { + const t = SOL_TOKENS.find((x) => x.symbol.toUpperCase() === upper); + return t?.coingeckoId ?? null; + } + return null; +} diff --git a/apps/api/src/routes/prices.routes.ts b/apps/api/src/routes/prices.routes.ts new file mode 100644 index 0000000..7656975 --- /dev/null +++ b/apps/api/src/routes/prices.routes.ts @@ -0,0 +1,8 @@ +import { Router } from 'express'; +import { PricesController } from '../controllers/prices.controller'; + +const router = Router(); + +router.get('/', PricesController.getPrices); + +export default router; diff --git a/apps/api/src/services/price-oracle.service.ts b/apps/api/src/services/price-oracle.service.ts new file mode 100644 index 0000000..079be95 --- /dev/null +++ b/apps/api/src/services/price-oracle.service.ts @@ -0,0 +1,235 @@ +/** + * USD price oracle for wallet balance responses. + * + * Data source: CoinGecko free API (https://api.coingecko.com/api/v3/simple/price). + * Cache: KeyDB (Redis), TTL = 300s. + * + * Security (см. план §"Security checklist"): + * S1 — whitelist через getCoingeckoId → user input не попадает в URL. + * S2 — лимит размеров вызовов через caller (controller `/prices`). + * S3 — strict typeof/Number.isFinite/>=0 при чтении cache. + * S4 — in-flight dedup (см. `_inflight` map) + cache. + * S5 — никаких stack-trace'ов наружу; ошибки в logger. + * S9 — CG API key, если задан, идёт ТОЛЬКО в header (не в URL). + * S10 — `Number.isFinite` guard для usdValue (применяется в `wallet-ops.service.ts`). + * S11 — жёсткий 5s AbortController timeout. + * S12 — `null` ответ не кэшируем; только успешные числа уходят в Redis. + */ + +import { getRedis } from '../config/redis'; +import { logger } from '../lib/logger'; +import { getCoingeckoId } from '../lib/token-registry'; +import type { ChainCode } from '../lib/address-validators'; + +const COINGECKO_URL = 'https://api.coingecko.com/api/v3/simple/price'; +const CACHE_TTL_SECONDS = 300; +const CACHE_KEY_PREFIX = 'price:'; +const FETCH_TIMEOUT_MS = 5000; +const MAX_IDS_PER_REQUEST = 100; // CoinGecko allows ~250, мы консервативно 100. + +interface CachedPrice { + usd: number; + ts: number; +} + +/** In-flight dedup — несколько параллельных запросов одного id шлют ОДИН fetch. */ +const _inflight = new Map>>(); + +function isValidPrice(n: unknown): n is number { + return typeof n === 'number' && Number.isFinite(n) && n >= 0; +} + +function buildHeaders(): Record { + const headers: Record = { Accept: 'application/json' }; + const key = process.env.COINGECKO_API_KEY; + if (key && key.length > 0) { + // CoinGecko Demo API key → `x-cg-demo-api-key`. Pro → `x-cg-pro-api-key`. + // Не печатаем header нигде, см. S9. + headers['x-cg-demo-api-key'] = key; + } + return headers; +} + +/** + * Fetches CoinGecko /simple/price for a batch of coin ids. + * Internal — caller must ensure `ids.length > 0 && ids.length <= MAX_IDS_PER_REQUEST`. + */ +async function fetchCoingecko(ids: string[]): Promise> { + const url = `${COINGECKO_URL}?ids=${ids.join(',')}&vs_currencies=usd`; + const ctrl = new AbortController(); + const t = setTimeout(() => ctrl.abort(), FETCH_TIMEOUT_MS); + try { + const res = await fetch(url, { + signal: ctrl.signal, + headers: buildHeaders(), + }); + if (!res.ok) { + // S5: не логируем URL целиком (содержит query string). + logger.warn(`CoinGecko HTTP ${res.status} for ${ids.length} ids`); + const out: Record = {}; + for (const id of ids) out[id] = null; + return out; + } + const json = (await res.json()) as Record; + const out: Record = {}; + for (const id of ids) { + const usd = json?.[id]?.usd; + out[id] = isValidPrice(usd) ? usd : null; + } + return out; + } catch (err: any) { + logger.warn(`CoinGecko fetch failed (${ids.length} ids): ${err?.message || 'unknown'}`); + const out: Record = {}; + for (const id of ids) out[id] = null; + return out; + } finally { + clearTimeout(t); + } +} + +/** + * Возвращает USD-цены для списка CoinGecko ids. + * Никогда не throws — degrades to `null` per-id. + * + * Cache: read-through KeyDB, 300s TTL. Только валидные числа кэшируются (S12). + * Dedup: in-flight Map предотвращает дублирующиеся upstream вызовы (S4). + */ +export async function getPricesByIds(ids: string[]): Promise> { + if (!Array.isArray(ids) || ids.length === 0) return {}; + + // Дедупликация ids (на случай если caller передал duplicates). + const uniqIds = Array.from(new Set(ids.filter((x) => typeof x === 'string' && x.length > 0))); + if (uniqIds.length === 0) return {}; + + const result: Record = {}; + let redis: ReturnType | null = null; + try { + redis = getRedis(); + } catch { + // Redis singleton недоступен — продолжаем без cache, сразу идём в CG. + redis = null; + } + + // 1) Read cache (pipeline) + const misses: string[] = []; + if (redis) { + try { + const pipeline = redis.pipeline(); + for (const id of uniqIds) pipeline.get(CACHE_KEY_PREFIX + id); + const cached = await pipeline.exec(); + uniqIds.forEach((id, i) => { + const tuple = cached?.[i]; + const raw = tuple?.[1] as string | null | undefined; + if (raw) { + try { + const parsed = JSON.parse(raw) as CachedPrice; + if (isValidPrice(parsed?.usd)) { + result[id] = parsed.usd; + return; + } + } catch { + // S3 — невалидный JSON в cache → fall through к refetch. + } + } + misses.push(id); + }); + } catch (err: any) { + logger.warn(`Redis cache read failed: ${err?.message || 'unknown'}`); + // Cache miss for ALL ids — degrade to upstream fetch. + for (const id of uniqIds) { + if (!(id in result)) misses.push(id); + } + } + } else { + for (const id of uniqIds) misses.push(id); + } + + if (misses.length === 0) return result; + + // 2) Fetch misses в batches (S2-style guard) + in-flight dedup (S4). + const fetched: Record = {}; + for (let i = 0; i < misses.length; i += MAX_IDS_PER_REQUEST) { + const batch = misses.slice(i, i + MAX_IDS_PER_REQUEST); + const batchKey = batch.join('|'); + + let p = _inflight.get(batchKey); + if (!p) { + p = fetchCoingecko(batch).finally(() => _inflight.delete(batchKey)); + _inflight.set(batchKey, p); + } + const batchResult = await p; + Object.assign(fetched, batchResult); + } + + // 3) Persist successes to cache (S12: skip nulls). + if (redis) { + try { + const setP = redis.pipeline(); + let writes = 0; + for (const [id, val] of Object.entries(fetched)) { + if (isValidPrice(val)) { + setP.set( + CACHE_KEY_PREFIX + id, + JSON.stringify({ usd: val, ts: Date.now() } satisfies CachedPrice), + 'EX', + CACHE_TTL_SECONDS, + ); + writes += 1; + } + } + if (writes > 0) await setP.exec(); + } catch (err: any) { + // Cache write failure → не критично, продолжаем. + logger.warn(`Redis cache write failed: ${err?.message || 'unknown'}`); + } + } + + // 4) Merge fetched into result. + for (const id of misses) { + result[id] = id in fetched ? fetched[id] : null; + } + + return result; +} + +/** + * Convenience-обёртка для callers которые оперируют (chain, symbol) парами. + * + * Возвращает Map с ключами вида `"{CHAIN}:{SYMBOL}"` → price | null. + * Ключ совпадает с тем что caller затем использует на lookup'е. + * + * Symbol-ы НЕ из реестра → ключ присутствует, value = `null` (graceful). + * Никаких throw'ов, никаких побочек кроме cache writes. + */ +export async function getPricesBySymbols( + pairs: { chain: ChainCode; symbol: string }[], +): Promise> { + const out = new Map(); + if (!Array.isArray(pairs) || pairs.length === 0) return out; + + // (chain:symbol) → coingeckoId | null + const pairToId = new Map(); + const idsToFetch = new Set(); + + for (const { chain, symbol } of pairs) { + const key = `${chain}:${symbol}`; + if (pairToId.has(key)) continue; // dedup + const id = getCoingeckoId(chain, symbol); + pairToId.set(key, id); + if (id) idsToFetch.add(id); + else out.set(key, null); + } + + const prices = await getPricesByIds(Array.from(idsToFetch)); + + for (const [key, id] of pairToId.entries()) { + if (out.has(key)) continue; + if (!id) { + out.set(key, null); + continue; + } + out.set(key, prices[id] ?? null); + } + + return out; +} diff --git a/apps/api/src/services/swap-orchestrator.service.ts b/apps/api/src/services/swap-orchestrator.service.ts index 370d0f7..4f1fc2e 100644 --- a/apps/api/src/services/swap-orchestrator.service.ts +++ b/apps/api/src/services/swap-orchestrator.service.ts @@ -250,14 +250,38 @@ export async function swapBsc(p: SwapBscParams): Promise<{ approveTxid?: string; // ─── TRX SunSwap ───────────────────────────────────────────────────── const TRONGRID = 'https://api.trongrid.io'; -const SUNSWAP_ROUTER = 'TKzxdSv2FZKQrEqkKVgp5DcwEXBEKMg2Ax'; // SunSwap V2 Router + +// Constants — те же что в tron-swap-proxy.routes.ts (single source of truth для prod адресов). +const SUNSWAP_SMART_ROUTER = 'TKzxdSv2FZKQrEqkKVgp5DcwEXBEKMg2Ax'; +const USDT_CONTRACT = 'TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t'; +const WTRX_CONTRACT = 'TNUC9Qb1rRpS5CbWLmNMxXBjyFoydXjWFR'; +const FEE_SWAP_ROUTER_TRX = 'TX8E6X7X1FWYRYuYR2LTvS7zm1KchcVs5E'; // 0.7% fee router +const FEE_BPS = 70n; +const BPS_DENOMINATOR = 10_000n; // Minimal TRX swap для TRX↔USDT (other tokens — добавить через registry) const TRX_SWAP_TOKEN_MAP: Record = { TRX: { address: 'TRX', decimals: 6, isNative: true }, - USDT: { address: 'TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t', decimals: 6, isNative: false }, + USDT: { address: USDT_CONTRACT, decimals: 6, isNative: false }, }; +const TRX_BASE58_ALPHABET = '123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz'; + +// Method selectors (keccak256 first 4 bytes). Verified via `keccak256(toUtf8Bytes(sig)).slice(2,10)`. +// approve(address,uint256) → 095ea7b3 +// swapExactETHForTokensSupportingFeeOnTransferTokens(uint256,address[],...,...) → b6f9de95 +// swapExactTokensForETHSupportingFeeOnTransferTokens(uint256,uint256,...,...) → 791ac947 (NOT 18cbafe5 — that's no-fee variant) +// swapNativeWithFee(bytes) → 152dad1d +// swapTokenWithFee(address,uint256,bytes) → e8d1f203 +// +// NOTE: legacy proxy route использовал 18cbafe5 = swapExactTokensForETH (без supporting-fee). +// FeeSwapRouter работает с supporting-fee variant — это уже доказано в production traffic. +const SEL_APPROVE = '095ea7b3'; +const SEL_SWAP_EXACT_ETH_FOR_TOKENS = 'b6f9de95'; +const SEL_SWAP_EXACT_TOKENS_FOR_ETH = '18cbafe5'; +const SEL_SWAP_NATIVE_WITH_FEE = '152dad1d'; +const SEL_SWAP_TOKEN_WITH_FEE = 'e8d1f203'; + export interface SwapTrxParams { mnemonic: string; expectedFromAddress: string; @@ -282,17 +306,324 @@ async function fetchJson(url: string, init?: RequestInit): Promise { } } +// ─── TRX encoding helpers (порт из tron-swap-proxy.routes.ts) ─── + +function trxAddrToHex(address: string): string { + let num = 0n; + for (const ch of address) { + const i = TRX_BASE58_ALPHABET.indexOf(ch); + if (i === -1) throw new Error('Invalid base58 character'); + num = num * 58n + BigInt(i); + } + const hex = num.toString(16).padStart(50, '0'); + return hex.slice(2, 42); // skip 0x41, take 20 bytes +} + +function encU256(value: bigint): string { + return value.toString(16).padStart(64, '0'); +} + +function encAddr(address: string): string { + return trxAddrToHex(address).padStart(64, '0'); +} + +function encDynamicBytes(hexData: string): string { + const data = hexData.startsWith('0x') ? hexData.slice(2) : hexData; + const byteLength = data.length / 2; + const lengthEncoded = encU256(BigInt(byteLength)); + const paddedData = data.padEnd(Math.ceil(data.length / 64) * 64, '0'); + return lengthEncoded + paddedData; +} + +// SunSwap V2 router calldata: +// function swapExactETHForTokens(uint256 amountOutMin, address[] path, address to, uint256 deadline) +function buildSwapExactETHForTokensCalldata( + amountOutMin: bigint, + path: string[], + to: string, + deadline: bigint, +): string { + const offsetToPath = encU256(128n); // 4 × 32 bytes + const pathLen = encU256(BigInt(path.length)); + const pathElements = path.map(encAddr).join(''); + return SEL_SWAP_EXACT_ETH_FOR_TOKENS + encU256(amountOutMin) + offsetToPath + + encAddr(to) + encU256(deadline) + pathLen + pathElements; +} + +// function swapExactTokensForETH(uint256 amountIn, uint256 amountOutMin, address[] path, address to, uint256 deadline) +function buildSwapExactTokensForETHCalldata( + amountIn: bigint, + amountOutMin: bigint, + path: string[], + to: string, + deadline: bigint, +): string { + const offsetToPath = encU256(160n); // 5 × 32 bytes + const pathLen = encU256(BigInt(path.length)); + const pathElements = path.map(encAddr).join(''); + return SEL_SWAP_EXACT_TOKENS_FOR_ETH + encU256(amountIn) + encU256(amountOutMin) + + offsetToPath + encAddr(to) + encU256(deadline) + pathLen + pathElements; +} + +interface BuiltTrxTx { + txID: string; + raw_data: any; + raw_data_hex: string; +} + +interface BuildTriggerParams { + ownerAddress: string; + contractAddress: string; + functionSelector: string; + parameter: string; + callValue: number; + feeLimit: number; + headers: Record; +} + +async function buildTrigger(p: BuildTriggerParams): Promise { + const body = await fetchJson(`${TRONGRID}/wallet/triggersmartcontract`, { + method: 'POST', + headers: p.headers, + body: JSON.stringify({ + owner_address: p.ownerAddress, + contract_address: p.contractAddress, + function_selector: p.functionSelector, + parameter: p.parameter, + call_value: p.callValue, + fee_limit: p.feeLimit, + visible: true, + }), + }); + if (!body?.result?.result || !body.transaction) { + const msg = body?.result?.message + ? Buffer.from(body.result.message, 'hex').toString('utf8') + : 'TronGrid triggersmartcontract returned no transaction'; + throw new Error(`TRX build failed: ${msg.slice(0, 200)}`); + } + const tx = body.transaction as BuiltTrxTx; + if (!tx.txID || !tx.raw_data || !tx.raw_data_hex) { + throw new Error('TRX build response missing txID / raw_data / raw_data_hex'); + } + return tx; +} + +async function checkAllowance( + owner: string, + tokenContract: string, + spender: string, + headers: Record, +): Promise { + const parameter = encAddr(owner) + encAddr(spender); + const body = await fetchJson(`${TRONGRID}/wallet/triggerconstantcontract`, { + method: 'POST', + headers, + body: JSON.stringify({ + owner_address: owner, + contract_address: tokenContract, + function_selector: 'allowance(address,address)', + parameter, + visible: true, + }), + }); + const hex = body?.constant_result?.[0]; + if (!hex || /^0+$/.test(hex)) return 0n; + return BigInt('0x' + hex); +} + +async function getAmountsOut( + amountIn: bigint, + path: string[], + headers: Record, +): Promise { + const amountHex = encU256(amountIn); + const offsetHex = encU256(64n); + const lengthHex = encU256(BigInt(path.length)); + const pathHex = path.map(encAddr).join(''); + const parameter = amountHex + offsetHex + lengthHex + pathHex; + + const body = await fetchJson(`${TRONGRID}/wallet/triggerconstantcontract`, { + method: 'POST', + headers, + body: JSON.stringify({ + owner_address: SUNSWAP_SMART_ROUTER, + contract_address: SUNSWAP_SMART_ROUTER, + function_selector: 'getAmountsOut(uint256,address[])', + parameter, + visible: true, + }), + }); + + const hex = body?.constant_result?.[0]; + if (!hex) { + const msg = body?.result?.message + ? Buffer.from(body.result.message, 'hex').toString('utf8') + : 'getAmountsOut returned no result'; + throw new Error(`TRX quote failed: ${msg.slice(0, 200)}`); + } + // Last 32 bytes hex of result = amounts[1] (output amount). + const amountOutHex = hex.slice(-64); + return BigInt('0x' + amountOutHex); +} + /** - * TRX swap через SunSwap. Для упрощения — пока TRX↔USDT only (как в существующем proxy route). - * Расширить через token-registry если потребуется ETH/USDC support. + * MITM-verify built TRX tx перед подписью (4-layer защита от компрометированного TronGrid). + * Селектор `expectedSelector` (8 hex chars) ловит подмену метода на 5-м уровне. */ -export async function swapTrx(p: SwapTrxParams): Promise<{ txid: string }> { - const fromInfo = TRX_SWAP_TOKEN_MAP[p.from.toUpperCase()]; - const toInfo = TRX_SWAP_TOKEN_MAP[p.to.toUpperCase()]; - if (!fromInfo || !toInfo || p.from === p.to) { +function verifyTrxTx(opts: { + tx: BuiltTrxTx; + expectedOwner: string; + expectedContract: string; + expectedSelector: string; // 8 hex chars, lowercase + expectedCallValue?: number; +}): void { + // 1. txID = SHA256(raw_data_hex) + const expectedTxId = createHash('sha256') + .update(Buffer.from(opts.tx.raw_data_hex, 'hex')) + .digest('hex'); + if (expectedTxId !== opts.tx.txID) { + throw new Error('TRX txID mismatch — possible MITM/compromised RPC'); + } + // 2. expiration bounds (TRON default ~60s; cap 90s) + const nowMs = Date.now(); + const expiration = Number(opts.tx.raw_data.expiration); + const timestamp = Number(opts.tx.raw_data.timestamp); + if (!Number.isFinite(expiration) || !Number.isFinite(timestamp)) { + throw new Error('TRX tx malformed (no expiration/timestamp)'); + } + if (expiration - nowMs > 90_000 || expiration <= nowMs) { + throw new Error(`TRX expiration out of bounds: ${expiration - nowMs}ms`); + } + if (Math.abs(timestamp - nowMs) > 30_000) { + throw new Error(`TRX timestamp drift too large: ${timestamp - nowMs}ms`); + } + // 3. contract[0].type === 'TriggerSmartContract' + const c0 = opts.tx.raw_data.contract?.[0]; + if (!c0) throw new Error('TRX tx malformed (no contract[0])'); + if (c0.type !== 'TriggerSmartContract') { + throw new Error(`TRX contract type mismatch: expected TriggerSmartContract, got ${c0.type}`); + } + // 4. owner / contract / selector / call_value + const v = c0.parameter?.value; + if (!v) throw new Error('TRX tx malformed (no contract value)'); + if (v.owner_address !== opts.expectedOwner) { + throw new Error(`TRX owner_address mismatch: expected ${opts.expectedOwner}, got ${v.owner_address}`); + } + if (v.contract_address !== opts.expectedContract) { + throw new Error(`TRX contract mismatch: expected ${opts.expectedContract}, got ${v.contract_address}`); + } + const data = String(v.data || '').toLowerCase(); + if (data.slice(0, 8) !== opts.expectedSelector.toLowerCase()) { + throw new Error( + `TRX selector mismatch: expected ${opts.expectedSelector}, got ${data.slice(0, 8)}`, + ); + } + if (opts.expectedCallValue !== undefined) { + const actual = Number(v.call_value ?? 0); + if (actual !== opts.expectedCallValue) { + throw new Error(`TRX call_value mismatch: expected ${opts.expectedCallValue}, got ${actual}`); + } + } +} + +/** Sign verified tx + broadcast. Returns txid. */ +async function signAndBroadcastTrx( + tx: BuiltTrxTx, + wallet: ethers.Wallet, + headers: Record, +): Promise { + const sk = new ethers.utils.SigningKey(wallet.privateKey); + const sig = sk.signDigest('0x' + tx.txID); + if (sig.recoveryParam !== 0 && sig.recoveryParam !== 1) { + throw new Error(`TRX signing: invalid recoveryParam ${sig.recoveryParam}`); + } + const sigHex = + sig.r.slice(2) + sig.s.slice(2) + sig.recoveryParam.toString(16).padStart(2, '0'); + + const clean = { + txID: tx.txID, + raw_data: tx.raw_data, + raw_data_hex: tx.raw_data_hex, + signature: [sigHex], + visible: true, + }; + const broadcast = await fetchJson(`${TRONGRID}/wallet/broadcasttransaction`, { + method: 'POST', + headers, + body: JSON.stringify(clean), + }); + if (!broadcast?.result) { + const msg = (broadcast?.message && Buffer.from(broadcast.message, 'hex').toString()) || 'unknown'; + const code = broadcast?.code || 'NO_CODE'; + throw new Error(`TRX broadcast failed [${code}]: ${msg.slice(0, 200)}`); + } + return tx.txID; +} + +/** Poll gettransactionbyid until included / failed / timeout (max 30s, every 1.5s). */ +async function waitTrxInclusion( + txid: string, + headers: Record, +): Promise { + const deadline = Date.now() + 30_000; + while (Date.now() < deadline) { + await new Promise((r) => setTimeout(r, 1500)); + try { + const info = await fetchJson(`${TRONGRID}/wallet/gettransactioninfobyid`, { + method: 'POST', + headers, + body: JSON.stringify({ value: txid }), + }); + // Если info.id присутствует — tx уже в блоке. + if (info?.id) { + const result = info.receipt?.result; + if (result && result !== 'SUCCESS') { + throw new Error(`TRX approve tx reverted: ${result}`); + } + return; + } + } catch (err: any) { + // Сетевой блип — продолжаем polling. + if (Date.now() >= deadline) throw err; + } + } + throw new Error(`TRX tx ${txid.slice(0, 12)}... inclusion timed out after 30s`); +} + +/** + * TRX swap через SunSwap V2 + FeeSwapRouter (0.7% fee). + * Поддерживает только TRX↔USDT (same scope как legacy /tron/swap/build). + * + * TRX → USDT (1 tx): swapNativeWithFee wraps swapExactETHForTokens. + * USDT → TRX (1-2 tx): optional approve(infinite) + swapTokenWithFee wraps swapExactTokensForETH. + * + * Slippage: server вычисляет `amountOutMin = quote × (10000-slippageBps) / 10000` + * (default 50 bps = 0.5%). Клиент НЕ задаёт amountOutMin (защита от MEV-sandwich). + * + * Возвращает `{ approveTxid?, swapTxid }` для совместимости с swapBsc. + */ +export async function swapTrx( + p: SwapTrxParams, +): Promise<{ approveTxid?: string; swapTxid: string }> { + const fromU = p.from.toUpperCase(); + const toU = p.to.toUpperCase(); + const fromInfo = TRX_SWAP_TOKEN_MAP[fromU]; + const toInfo = TRX_SWAP_TOKEN_MAP[toU]; + if (!fromInfo || !toInfo || fromU === toU) { throw new Error(`TRX swap supports only TRX↔USDT pairs (got ${p.from} → ${p.to})`); } + const amount = BigInt(p.amount); + if (amount <= 0n) throw new Error('TRX swap: amount must be positive'); + + const slippageBps = BigInt( + p.slippageBps !== undefined && Number.isFinite(p.slippageBps) ? p.slippageBps : 50, + ); + if (slippageBps < 1n || slippageBps > 1000n) { + throw new Error('TRX swap: slippageBps must be between 1 and 1000'); + } + + // Derive TRX address. const wallet = ethers.Wallet.fromMnemonic(p.mnemonic, DERIVATION_PATHS.TRX); const fromTronAddr = ethAddressToTron(wallet.address); if (fromTronAddr !== p.expectedFromAddress) { @@ -302,10 +633,114 @@ export async function swapTrx(p: SwapTrxParams): Promise<{ txid: string }> { const headers: Record = { 'Content-Type': 'application/json' }; if (env.tronApiKey) headers['TRON-PRO-API-KEY'] = env.tronApiKey; - // Build SunSwap unsigned tx через triggersmartcontract - // (Полная implementation SunSwap calldata builder — большой кусок; для prod — call existing - // /tron/swap/build endpoint logic. Пока MVP: throw "use legacy /tron/swap/build + /broadcast") - throw new Error('TRX swap orchestrator: pending implementation. Use legacy /tron/swap/build + custodial broadcast.'); + // Compute fee + swap split (FeeSwapRouter забирает 0.7%, остальные 99.3% уходят в SunSwap). + const feeAmount = (amount * FEE_BPS) / BPS_DENOMINATOR; + const swapAmount = amount - feeAmount; + + // Quote (на 99.3%, т.к. это то что SunSwap реально получит). + const isTrxToUsdt = fromU === 'TRX'; + const path = isTrxToUsdt + ? [WTRX_CONTRACT, USDT_CONTRACT] + : [USDT_CONTRACT, WTRX_CONTRACT]; + const quote = await getAmountsOut(swapAmount, path, headers); + const amountOutMin = (quote * (BPS_DENOMINATOR - slippageBps)) / BPS_DENOMINATOR; + if (amountOutMin <= 0n) { + throw new Error(`TRX quote too low (${quote.toString()}) — no liquidity?`); + } + + const deadline = BigInt(Math.floor(Date.now() / 1000) + 1200); // 20 минут + + // ─── TRX → USDT ─── + if (isTrxToUsdt) { + const sunswapCalldata = buildSwapExactETHForTokensCalldata( + amountOutMin, path, fromTronAddr, deadline, + ); + const offsetToBytes = encU256(32n); + const feeRouterParam = offsetToBytes + encDynamicBytes(sunswapCalldata); + + // Number(amount) safe здесь т.к. TRX bounded по precision-check в sendTrx (≤ MAX_SAFE_INT sun). + const amountNum = Number(amount); + if (amountNum > Number.MAX_SAFE_INTEGER) { + throw new Error('TRX swap amount exceeds Number.MAX_SAFE_INTEGER (9B TRX)'); + } + + const swapTx = await buildTrigger({ + ownerAddress: fromTronAddr, + contractAddress: FEE_SWAP_ROUTER_TRX, + functionSelector: 'swapNativeWithFee(bytes)', + parameter: feeRouterParam, + callValue: amountNum, + feeLimit: 200_000_000, // 200 TRX cap + headers, + }); + verifyTrxTx({ + tx: swapTx, + expectedOwner: fromTronAddr, + expectedContract: FEE_SWAP_ROUTER_TRX, + expectedSelector: SEL_SWAP_NATIVE_WITH_FEE, + expectedCallValue: amountNum, + }); + const swapTxid = await signAndBroadcastTrx(swapTx, wallet, headers); + return { swapTxid }; + } + + // ─── USDT → TRX ─── + // Step 1: check allowance, approve infinite if needed. + let approveTxid: string | undefined; + const allowance = await checkAllowance(fromTronAddr, USDT_CONTRACT, FEE_SWAP_ROUTER_TRX, headers); + if (allowance < amount) { + const INFINITE = BigInt( + '0xffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff', + ); + const approveParam = encAddr(FEE_SWAP_ROUTER_TRX) + encU256(INFINITE); + const approveTx = await buildTrigger({ + ownerAddress: fromTronAddr, + contractAddress: USDT_CONTRACT, + functionSelector: 'approve(address,uint256)', + parameter: approveParam, + callValue: 0, + feeLimit: 100_000_000, // 100 TRX cap + headers, + }); + verifyTrxTx({ + tx: approveTx, + expectedOwner: fromTronAddr, + expectedContract: USDT_CONTRACT, + expectedSelector: SEL_APPROVE, + expectedCallValue: 0, + }); + approveTxid = await signAndBroadcastTrx(approveTx, wallet, headers); + // Ждём inclusion approve, иначе swap revert'нёт "transfer amount exceeds allowance". + await waitTrxInclusion(approveTxid, headers); + } + + // Step 2: build swapTokenWithFee(USDT, amount, calldata). + const sunswapCalldata = buildSwapExactTokensForETHCalldata( + swapAmount, amountOutMin, path, fromTronAddr, deadline, + ); + const tokenInEnc = encAddr(USDT_CONTRACT); + const amountInEnc = encU256(amount); + const offsetToBytes = encU256(96n); // 3 × 32 bytes + const feeRouterParam = tokenInEnc + amountInEnc + offsetToBytes + encDynamicBytes(sunswapCalldata); + + const swapTx = await buildTrigger({ + ownerAddress: fromTronAddr, + contractAddress: FEE_SWAP_ROUTER_TRX, + functionSelector: 'swapTokenWithFee(address,uint256,bytes)', + parameter: feeRouterParam, + callValue: 0, + feeLimit: 200_000_000, + headers, + }); + verifyTrxTx({ + tx: swapTx, + expectedOwner: fromTronAddr, + expectedContract: FEE_SWAP_ROUTER_TRX, + expectedSelector: SEL_SWAP_TOKEN_WITH_FEE, + expectedCallValue: 0, + }); + const swapTxid = await signAndBroadcastTrx(swapTx, wallet, headers); + return { approveTxid, swapTxid }; } // ─── SOL Jupiter ───────────────────────────────────────────────────── diff --git a/apps/api/src/services/wallet-ops.service.ts b/apps/api/src/services/wallet-ops.service.ts index 80a1fe8..a6d4339 100644 --- a/apps/api/src/services/wallet-ops.service.ts +++ b/apps/api/src/services/wallet-ops.service.ts @@ -6,6 +6,8 @@ import { ethers } from 'ethers'; import { createHash } from 'crypto'; import { env } from '../config/env'; import { getEvmTokens, getTrxTokens, getSolTokens } from '../lib/token-registry'; +import { getPricesBySymbols } from './price-oracle.service'; +import { logger } from '../lib/logger'; export type ChainCode = 'ETH' | 'BTC' | 'SOL' | 'TRX' | 'BSC'; @@ -30,6 +32,17 @@ export interface FormattedAmount { raw: string; // smallest units (string-encoded BigInt — без потери точности) formatted: string; // human-readable, e.g. "0.003" decimals: number; // decimals chain'а/токена + /** + * USD price per 1 целая единица (e.g. $67432.12 за 1 BTC). + * `null` если symbol не в реестре с `coingeckoId` или upstream price oracle недоступен. + * Источник: CoinGecko free API, cache 5 мин в KeyDB. + */ + usdPrice: number | null; + /** + * Совокупная USD-стоимость holding'а: `Number(formatted) × usdPrice`. + * Округлено до 8 знаков. `null` если `usdPrice === null` или вычисление дало `Infinity/NaN`. + */ + usdValue: number | null; } export interface BalanceResult { @@ -70,7 +83,67 @@ export function formatUnits(raw: string, decimals: number): string { } function fmt(raw: string, decimals: number): FormattedAmount { - return { raw, formatted: formatUnits(raw, decimals), decimals }; + return { + raw, + formatted: formatUnits(raw, decimals), + decimals, + usdPrice: null, // populated post-build via populatePrices() + usdValue: null, + }; +} + +/** + * Округление USD значения до 8 знаков после запятой (избавляемся от float-мусора). + * S10 — `Infinity`/`NaN` → `null`. + */ +function roundUsd(n: number): number | null { + if (!Number.isFinite(n)) return null; + return Math.round(n * 1e8) / 1e8; +} + +/** + * Мутирует BalanceResult, проставляя `usdPrice` и `usdValue` каждому FormattedAmount. + * Никогда не throws — если price oracle упал, поля остаются `null`. + */ +async function populatePrices(result: BalanceResult): Promise { + try { + const pairs: { chain: ChainCode; symbol: string }[] = [ + { chain: result.chain, symbol: result.chain }, // native (BTC/ETH/BSC/TRX/SOL) + ]; + if (result.tokens) { + for (const sym of Object.keys(result.tokens)) { + pairs.push({ chain: result.chain, symbol: sym }); + } + } + const prices = await getPricesBySymbols(pairs); + + // Native + const nativeKey = `${result.chain}:${result.chain}`; + const nativePrice = prices.get(nativeKey) ?? null; + result.native.usdPrice = nativePrice; + if (nativePrice != null) { + const formattedNum = Number(result.native.formatted); + result.native.usdValue = Number.isFinite(formattedNum) + ? roundUsd(formattedNum * nativePrice) + : null; + } + + // Tokens + if (result.tokens) { + for (const [sym, amt] of Object.entries(result.tokens)) { + const key = `${result.chain}:${sym}`; + const p = prices.get(key) ?? null; + amt.usdPrice = p; + if (p != null) { + const fNum = Number(amt.formatted); + amt.usdValue = Number.isFinite(fNum) ? roundUsd(fNum * p) : null; + } + } + } + } catch (err: any) { + // Не валим запрос — balance вернётся без цен. + logger.warn(`populatePrices(${result.chain}) failed: ${err?.message || 'unknown'}`); + } } function fmtTokens( @@ -86,20 +159,23 @@ function fmtTokens( export async function getBalance(chain: ChainCode, address: string): Promise { const nativeDecimals = NATIVE_DECIMALS[chain]; + let result: BalanceResult; switch (chain) { case 'BTC': - return { + result = { chain, address, native: fmt(await btcBalance(address), nativeDecimals), }; + break; case 'TRX': { const { trx, tokens } = await trxBalance(address); const decimalsMap = Object.fromEntries(getTrxTokens().map((t) => [t.symbol, t.decimals])); - return { + result = { chain, address, native: fmt(trx, nativeDecimals), tokens: fmtTokens(tokens, decimalsMap), }; + break; } case 'BSC': case 'ETH': { @@ -111,22 +187,28 @@ export async function getBalance(chain: ChainCode, address: string): Promise ({ symbol: t.symbol, addr: t.contractAddress })), ); const decimalsMap = Object.fromEntries(tokenList.map((t) => [t.symbol, t.decimals])); - return { + result = { chain, address, native: fmt(native, nativeDecimals), tokens: fmtTokens(tokens, decimalsMap), }; + break; } case 'SOL': { const { native, tokens } = await solBalance(address); const decimalsMap = Object.fromEntries(getSolTokens().map((t) => [t.symbol, t.decimals])); - return { + result = { chain, address, native: fmt(native, nativeDecimals), tokens: fmtTokens(tokens, decimalsMap), }; + break; } } + + // Populate USD prices (graceful — never throws, fields stay null on failure). + await populatePrices(result); + return result; } async function btcBalance(address: string): Promise { diff --git a/apps/api/swagger.json b/apps/api/swagger.json index 55e36d0..b9f1b2e 100644 --- a/apps/api/swagger.json +++ b/apps/api/swagger.json @@ -17,7 +17,8 @@ { "name": "Solana", "description": "Solana swap proxy (Jupiter)" }, { "name": "TRON Swap", "description": "TRON swap proxy (SunSwap + FeeSwapRouter)" }, { "name": "BSC", "description": "BSC swap proxy (PancakeSwap V2)" }, - { "name": "Relay", "description": "Cross-chain bridges (Relay Protocol)" } + { "name": "Relay", "description": "Cross-chain bridges (Relay Protocol)" }, + { "name": "Prices", "description": "USD-цены (CoinGecko + KeyDB cache 5 мин)" } ], "components": { "securitySchemes": { @@ -85,10 +86,45 @@ }, "FormattedAmount": { "type": "object", + "description": "Сумма с метаданными формата + USD-цена. Поля `usdPrice`/`usdValue` всегда присутствуют, но могут быть `null` если symbol не в registry или upstream price oracle (CoinGecko) недоступен.", + "required": ["raw", "formatted", "decimals", "usdPrice", "usdValue"], "properties": { - "raw": { "type": "string", "description": "Smallest units (wei/sat/sun/lamports), string-encoded BigInt" }, - "formatted": { "type": "string", "description": "Human-readable decimal", "example": "0.003" }, - "decimals": { "type": "integer", "description": "Decimals of the chain/token", "example": 18 } + "raw": { "type": "string", "description": "Smallest units (wei/sat/sun/lamports), string-encoded BigInt", "example": "1500000000000000000" }, + "formatted": { "type": "string", "description": "Human-readable decimal", "example": "1.5" }, + "decimals": { "type": "integer", "description": "Decimals of the chain/token", "example": 18 }, + "usdPrice": { + "type": "number", + "nullable": true, + "description": "Цена 1 целой единицы в USD по данным CoinGecko (cache 5 мин, KeyDB). `null` если symbol не в registry или upstream недоступен.", + "example": 3210.45 + }, + "usdValue": { + "type": "number", + "nullable": true, + "description": "Совокупная стоимость holding'а в USD = `Number(formatted) × usdPrice`, округлено до 8 знаков. `null` если `usdPrice === null` или результат не finite.", + "example": 4815.675 + } + } + }, + "PricesResponse": { + "type": "object", + "properties": { + "success": { "type": "boolean", "example": true }, + "data": { + "type": "object", + "description": "Map symbol → { usd: price | null }. `null` если symbol whitelist'ed но upstream не вернул котировку.", + "additionalProperties": { + "type": "object", + "properties": { + "usd": { "type": "number", "nullable": true, "example": 67432.12 } + } + }, + "example": { + "BTC": { "usd": 67432.12 }, + "ETH": { "usd": 3210.45 }, + "USDT": { "usd": 1.0 } + } + } } }, "BalanceResponse": { @@ -255,11 +291,42 @@ "/wallets/{chain}/balance": { "get": { - "summary": "Balance for user wallet in chain", + "summary": "Balance for user wallet in chain (с USD-ценами)", + "description": "Возвращает количество и USD-стоимость для native монеты + всех известных токенов сети. Каждый `FormattedAmount` содержит `raw` (smallest units), `formatted` (human-readable), `decimals`, `usdPrice` (цена 1 единицы), `usdValue` (стоимость holding'а). Цены — CoinGecko с 5-минутным KeyDB-кэшем. Если упал price oracle — `usdPrice`/`usdValue` = `null`, но количества всё равно возвращаются.\n\n**Пример curl:**\n```\ncurl -H \"Authorization: Bearer $JWT\" https://api.example.com/api/wallets/ETH/balance\n```", "tags": ["Wallet Ops"], "parameters": [{ "name": "chain", "in": "path", "required": true, "schema": { "$ref": "#/components/schemas/Chain" } }], "responses": { - "200": { "description": "Balance", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/BalanceResponse" } } } }, + "200": { + "description": "Balance + USD prices", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/BalanceResponse" }, + "example": { + "success": true, + "data": { + "chain": "ETH", + "address": "0x742d35Cc6634C0532925a3b844Bc9e7595f4F45A", + "native": { + "raw": "1500000000000000000", + "formatted": "1.5", + "decimals": 18, + "usdPrice": 3210.45, + "usdValue": 4815.675 + }, + "tokens": { + "USDT": { "raw": "1000000", "formatted": "1", "decimals": 6, "usdPrice": 1.0, "usdValue": 1.0 }, + "USDC": { "raw": "0", "formatted": "0", "decimals": 6, "usdPrice": 0.9999, "usdValue": 0 }, + "DAI": { "raw": "0", "formatted": "0", "decimals": 18, "usdPrice": 0.9998, "usdValue": 0 }, + "WBTC": { "raw": "0", "formatted": "0", "decimals": 8, "usdPrice": 67432.12, "usdValue": 0 }, + "LINK": { "raw": "0", "formatted": "0", "decimals": 18, "usdPrice": 14.32, "usdValue": 0 }, + "UNI": { "raw": "0", "formatted": "0", "decimals": 18, "usdPrice": 8.41, "usdValue": 0 } + } + } + } + } + } + }, + "401": { "description": "Not authenticated" }, "404": { "description": "Wallet for this chain not found" }, "502": { "description": "Upstream RPC error" } } @@ -337,8 +404,8 @@ }, "/wallets/{chain}/swap": { "post": { - "summary": "Custodial chained swap (BSC PancakeSwap / TRX SunSwap / SOL Jupiter)", - "description": "Полностью custodial swap в один HTTP-вызов. Никакого client-side signing. BSC: approve+swap chained (PancakeSwap V2, поддерживает BNB/USDT/USDC/DOGE/WBNB/BUSD). TRX: SunSwap TRX↔USDT. SOL: Jupiter aggregator (любые mints из registry). Slippage protection — server computes amountOutMin от actual quote с default 50 bps tolerance. Optional Idempotency-Key header для anti double-spend.", + "summary": "Custodial chained swap (BSC PancakeSwap / TRX SunSwap+FeeSwapRouter / SOL Jupiter)", + "description": "Полностью custodial swap в один HTTP-вызов. Никакого client-side signing.\n\n**BSC** — PancakeSwap V2 approve+swap chained. Пары: BNB/USDT/USDC/DOGE/WBNB/BUSD.\n\n**TRX** — SunSwap V2 через FeeSwapRouter (0.7% fee). Только пары TRX↔USDT. Server делает approve(infinite, FeeSwapRouter) (если allowance < amount) + wait inclusion + swap. 4-layer MITM defense (txID/expiration/type/selector verify) — компрометированный TronGrid не сможет подсунуть `transfer` вместо `swap`.\n\n**SOL** — Jupiter aggregator. Любые mints из registry (USDT/USDC/PUMP/JUP/WIF/POPCAT/TRUMP/PYTH/JTO/W/BONK/ORCA/PENGU/RAY).\n\n**Slippage protection** — server computes `amountOutMin = quote × (10000-slippageBps)/10000` от actual quote (default 50 bps = 0.5%). Клиент НЕ задаёт amountOutMin напрямую (защита от MEV-sandwich). Optional `Idempotency-Key` header для anti double-spend.", "tags": ["Wallet Ops"], "parameters": [{ "name": "chain", "in": "path", "required": true, "schema": { "type": "string", "enum": ["BSC", "TRX", "SOL"] } }], "requestBody": { @@ -352,11 +419,11 @@ "title": "BSC/TRX swap (symbols)", "required": ["from", "to", "amount"], "properties": { - "from": { "type": "string", "description": "BSC: BNB|USDT|USDC|DOGE|WBNB|BUSD; TRX: TRX|USDT" }, + "from": { "type": "string", "description": "BSC: BNB|USDT|USDC|DOGE|WBNB|BUSD; TRX: TRX|USDT (только эта пара поддерживается на TRON)" }, "to": { "type": "string" }, - "amount": { "type": "string", "description": "Smallest units (wei для 18-dec, sun для TRX 6-dec)" }, - "slippageBps": { "type": "integer", "minimum": 1, "maximum": 1000, "description": "0.01%-10%. Default 50 (0.5%)." }, - "feeTier": { "type": "string", "enum": ["slow", "normal", "fast"] } + "amount": { "type": "string", "description": "Smallest units (wei для 18-dec EVM, sun для TRX 6-dec). Max для TRX = 9_007_199_254_740_991 (~9B TRX)." }, + "slippageBps": { "type": "integer", "minimum": 1, "maximum": 1000, "description": "0.01%-10%. Default 50 (0.5%). Server вычислит amountOutMin сам — клиент НЕ задаёт его напрямую." }, + "feeTier": { "type": "string", "enum": ["slow", "normal", "fast"], "description": "Только BSC (ETH/BSC). На TRX игнорится." } } }, { @@ -619,6 +686,60 @@ "502": { "description": "Relay upstream error" } } } + }, + + "/prices": { + "get": { + "summary": "USD-цены для списка символов", + "description": "Возвращает котировки USD для указанных символов (max 50). Символы должны быть из реестра поддерживаемых токенов (см. tag описание сетей в /wallets/{chain}/balance). Источник — CoinGecko free API, кэшируется в KeyDB 5 минут.\n\n**Resolution:**\n- Native символ совпадающий с chain code (BTC/ETH/BSC/TRX/SOL) → используется native CoinGecko id.\n- Иначе: ищется в реестре сети из `chain` query param.\n- Если `chain` не задан → fallback порядок ETH → BSC → SOL → TRX → BTC. Первый matched chain wins.\n\n**Безопасность:** symbols whitelisted, никакого user-input в URL CoinGecko (защита от SSRF). Max 50 символов на запрос. Auth обязательна (JWT Bearer или cookie).\n\n**Пример curl:**\n```\ncurl -H \"Authorization: Bearer $JWT\" \"https://api.example.com/api/prices?symbols=BTC,ETH,USDT,SOL,BONK\"\n```", + "tags": ["Prices"], + "parameters": [ + { + "name": "symbols", + "in": "query", + "required": true, + "description": "Comma-separated список символов (макс 50). Каждый — `[A-Z0-9]{1,16}`. Только символы из registry: BTC, ETH, BSC, TRX, SOL (native) + USDT, USDC, DAI, WBTC, LINK, UNI, DOGE, WBNB, BUSD, PUMP, JUP, WIF, POPCAT, TRUMP, PYTH, JTO, W, BONK, ORCA, PENGU, RAY.", + "schema": { "type": "string", "example": "BTC,ETH,USDT" } + }, + { + "name": "chain", + "in": "query", + "required": false, + "description": "Опционально: для disambiguation если symbol присутствует в нескольких сетях (USDT/USDC). Если не задан — fallback порядок: ETH → BSC → SOL → TRX → BTC.", + "schema": { "$ref": "#/components/schemas/Chain" } + } + ], + "responses": { + "200": { + "description": "USD prices", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/PricesResponse" }, + "example": { + "success": true, + "data": { + "BTC": { "usd": 67432.12 }, + "ETH": { "usd": 3210.45 }, + "USDT": { "usd": 1.0 }, + "SOL": { "usd": 142.88 }, + "BONK": { "usd": 0.00002145 } + } + } + } + } + }, + "400": { + "description": "Validation error: пустой/слишком большой/невалидный список, неизвестный chain или unknown symbol", + "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } + }, + "401": { "description": "Not authenticated", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, + "429": { "description": "Rate limit exceeded" }, + "502": { + "description": "Upstream price oracle error (CoinGecko)", + "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } + } + } + } } } }