From 5898a6c1e2df180158314aa47e30f422f12d95f7 Mon Sep 17 00:00:00 2001 From: ZOMBIIIIIII <120676065+Metaton241@users.noreply.github.com> Date: Thu, 14 May 2026 18:01:09 +0300 Subject: [PATCH] efeidjeie --- apps/api/src/app.ts | 8 +- apps/api/src/controllers/wallet.controller.ts | 285 ++- apps/api/src/lib/swap-quote-cache.ts | 146 ++ apps/api/src/routes/bsc-swap-proxy.routes.ts | 218 -- apps/api/src/routes/sol-swap-proxy.routes.ts | 210 -- apps/api/src/routes/tron-swap-proxy.routes.ts | 499 ---- apps/api/src/routes/wallet.routes.ts | 3 + .../src/services/swap-orchestrator.service.ts | 468 +++- apps/api/swagger.json | 2024 ++++++++++++++--- 9 files changed, 2488 insertions(+), 1373 deletions(-) create mode 100644 apps/api/src/lib/swap-quote-cache.ts delete mode 100644 apps/api/src/routes/bsc-swap-proxy.routes.ts delete mode 100644 apps/api/src/routes/sol-swap-proxy.routes.ts delete mode 100644 apps/api/src/routes/tron-swap-proxy.routes.ts diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts index 680e3b0..5b7e88d 100644 --- a/apps/api/src/app.ts +++ b/apps/api/src/app.ts @@ -13,10 +13,7 @@ import { errorHandler } from './middleware/error-handler'; import walletRoutes from './routes/wallet.routes'; import relayProxyRoutes from './routes/relay-proxy.routes'; import tronProxyRoutes from './routes/tron-proxy.routes'; -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(); @@ -105,10 +102,9 @@ app.use('/api/wallets/:chain/send', ...protect, sensitiveLimiter); app.use('/api/wallets', ...protect, mutateLimiter, walletRoutes); app.use('/api/relay', ...protect, mutateLimiter, relayProxyRoutes); app.use('/api/tron', ...protect, mutateLimiter, tronProxyRoutes); -app.use('/api/sol/swap', ...protect, mutateLimiter, solSwapProxyRoutes); -app.use('/api/tron/swap', ...protect, mutateLimiter, tronSwapProxyRoutes); app.use('/api/btc', ...protect, mutateLimiter, btcProxyRoutes); -app.use('/api/bsc/swap', ...protect, mutateLimiter, bscSwapProxyRoutes); +// Legacy non-custodial swap proxies (/api/bsc/swap, /api/sol/swap, /api/tron/swap) +// УДАЛЕНЫ. Custodial 2-step swap живёт под /api/wallets/{chain}/swap{,/quote}. // USD-цены (CoinGecko + KeyDB cache). GET-only, auth required, max 50 symbols. app.use('/api/prices', ...protect, mutateLimiter, pricesRoutes); diff --git a/apps/api/src/controllers/wallet.controller.ts b/apps/api/src/controllers/wallet.controller.ts index 9b0b879..83ffa65 100644 --- a/apps/api/src/controllers/wallet.controller.ts +++ b/apps/api/src/controllers/wallet.controller.ts @@ -7,7 +7,23 @@ import { isValidAddress, isValidAmount, ChainCode } from '../lib/address-validat import { generateMnemonic, deriveAllAddresses, ALL_CHAINS } from '../services/wallet-generator.service'; import { encryptMnemonic, decryptMnemonic, isCryptoReady } from '../services/crypto.service'; import { signAndBroadcast, signAndBroadcastRawEvm, signAndBroadcastSolanaTx, signAndBroadcastSolanaInstructions } from '../services/wallet-signer.service'; -import { swapBsc, swapTrx, swapSol } from '../services/swap-orchestrator.service'; +import { + quoteBsc, executeBsc, + quoteTrx, executeTrx, + quoteSol, executeSol, + type SwapQuoteRaw, type QuoteSolResult, +} from '../services/swap-orchestrator.service'; +import { saveQuote, getQuote, deleteQuote, QUOTE_TTL_SECONDS, type CachedSwapQuote } from '../lib/swap-quote-cache'; +import { generateUlid } from '../utils/ulid'; +import { getPricesBySymbols } from '../services/price-oracle.service'; +import { SOL_TOKENS } from '../lib/token-registry'; + +const SOL_WRAPPED_NATIVE_MINT = 'So11111111111111111111111111111111111111112'; +function solMintToSymbol(mint: string): string | null { + if (mint === SOL_WRAPPED_NATIVE_MINT) return 'SOL'; + const t = SOL_TOKENS.find((x) => x.mint === mint); + return t?.symbol ?? null; +} import { getEvmFeeTiers, type FeeTier } from '../services/gas-oracle.service'; import { applyEvmTxPolicy } from '../lib/evm-tx-policy'; import { getRelayTrustedAddresses } from '../lib/relay-trusted-cache'; @@ -651,13 +667,212 @@ export const WalletController = { }, /** - * POST /api/wallets/:chain/swap — chained custodial swap. - * BSC: PancakeSwap V2 — approve (если token-to-anything) + swap, sign+broadcast в одном вызове. - * TRX: SunSwap — build + sign + broadcast (TRX↔USDT). - * SOL: Jupiter — quote + swap + sign + broadcast. + * POST /api/wallets/:chain/swap/quote — preview расчёт ПЕРЕД execute. * - * Body для BSC/TRX: { from, to, amount, slippageBps?, feeTier? } — symbols (BNB, USDT, DOGE, USDC). - * Body для SOL: { inputMint, outputMint, amount, slippageBps? } — mint addresses. + * Read-only — НЕ broadcast'ит ничего. Возвращает quoteId (ULID), expectedOut, + * minOut, slippage, network fee, approveRequired, route. Quote сохраняется в KeyDB + * с TTL 30s — затем юзер шлёт POST /:chain/swap с {quoteId} для execute. + * + * Body (BSC/TRX): { from, to, amount, slippageBps?, feeTier? } — symbols. + * Body (SOL): { inputMint, outputMint, amount, slippageBps? } — mints. + */ + async quoteSwap(req: Request, res: Response) { + const userId = req.auth!.userId; + const chain = String(req.params.chain).toUpperCase() as 'BSC' | 'TRX' | 'SOL'; + if (chain !== 'BSC' && chain !== 'TRX' && chain !== 'SOL') { + res.status(400).json({ success: false, error: 'Quote supported only on BSC, TRX, SOL.' }); + return; + } + + try { + const wallet = await WalletModel.findByUserAndChain(userId, chain as ChainCode); + if (!wallet) { + res.status(404).json({ success: false, error: 'Wallet not found for this chain' }); + return; + } + + let raw: SwapQuoteRaw | QuoteSolResult; + let cacheParams: CachedSwapQuote['params']; + let lockedJupiterQuote: any | undefined; + + if (chain === 'BSC') { + const { from, to, amount, slippageBps, feeTier } = req.body ?? {}; + if (typeof from !== 'string' || typeof to !== 'string' || typeof amount !== 'string') { + res.status(400).json({ success: false, error: 'BSC quote body: {from, to, amount} required as strings' }); + return; + } + raw = await quoteBsc({ + fromAddress: wallet.address, + from, to, amount, + slippageBps: typeof slippageBps === 'number' ? slippageBps : undefined, + feeTier: feeTier === 'slow' || feeTier === 'normal' || feeTier === 'fast' ? feeTier : undefined, + }); + cacheParams = { + from, to, amount, + slippageBps: raw.slippageBps, + feeTier: feeTier === 'slow' || feeTier === 'normal' || feeTier === 'fast' ? feeTier : undefined, + }; + } else if (chain === 'TRX') { + const { from, to, amount, slippageBps } = req.body ?? {}; + if (typeof from !== 'string' || typeof to !== 'string' || typeof amount !== 'string') { + res.status(400).json({ success: false, error: 'TRX quote body: {from, to, amount} required as strings' }); + return; + } + raw = await quoteTrx({ + fromAddress: wallet.address, + from, to, amount, + slippageBps: typeof slippageBps === 'number' ? slippageBps : undefined, + }); + cacheParams = { from, to, amount, slippageBps: raw.slippageBps }; + } else { + const { inputMint, outputMint, amount, slippageBps } = req.body ?? {}; + if (typeof inputMint !== 'string' || typeof outputMint !== 'string' || typeof amount !== 'string') { + res.status(400).json({ success: false, error: 'SOL quote body: {inputMint, outputMint, amount} required as strings' }); + return; + } + const solQuote = await quoteSol({ + inputMint, outputMint, amount, + slippageBps: typeof slippageBps === 'number' ? slippageBps : undefined, + }); + raw = solQuote; + lockedJupiterQuote = solQuote.jupiterQuoteResponse; + cacheParams = { inputMint, outputMint, amount, slippageBps: solQuote.slippageBps }; + } + + // USD enrichment (graceful — null если CoinGecko недоступен). + // BSC/TRX use route symbols; SOL uses 'SOL' for native + first hop label as fallback. + let fromSymbol: string; + let toSymbol: string; + if (chain === 'SOL') { + // Для SOL route — DEX labels, не coin symbols. Используем mint→symbol lookup из registry. + const { inputMint, outputMint } = req.body ?? {}; + fromSymbol = solMintToSymbol(inputMint) || 'SOL'; + toSymbol = solMintToSymbol(outputMint) || 'SOL'; + } else { + fromSymbol = raw.route[0]; + toSymbol = raw.route[raw.route.length - 1]; + } + + const priceMap = await getPricesBySymbols([ + { chain, symbol: fromSymbol }, + { chain, symbol: toSymbol }, + { chain, symbol: raw.networkFee.asset }, + ]).catch(() => new Map()); + + const fromPriceUsd = priceMap.get(`${chain}:${fromSymbol}`) ?? null; + const toPriceUsd = priceMap.get(`${chain}:${toSymbol}`) ?? null; + const feePriceUsd = priceMap.get(`${chain}:${raw.networkFee.asset}`) ?? null; + + // Decimal formatting helper. + const fmtUnits = (raw: string, decimals: number): string => { + try { + const big = BigInt(raw); + const divisor = 10n ** BigInt(decimals); + const whole = big / divisor; + const frac = big % divisor; + if (frac === 0n) return whole.toString(); + const fracStr = frac.toString().padStart(decimals, '0').replace(/0+$/, ''); + return `${whole}.${fracStr}`; + } catch { + return raw; + } + }; + const toUsd = (rawAmount: string, decimals: number, price: number | null): number | null => { + if (price == null) return null; + try { + const big = BigInt(rawAmount); + const divisor = 10n ** BigInt(decimals); + // BigInt → Number conversion для USD (USD precision 6 sig figs достаточно для UI). + const whole = Number(big / divisor); + const frac = Number(big % divisor) / Number(divisor); + return (whole + frac) * price; + } catch { + return null; + } + }; + + const networkFeeAssetDecimals = raw.networkFee.asset === 'TRX' ? 6 + : raw.networkFee.asset === 'SOL' ? 9 + : 18; // ETH/BSC native + const networkFeeUsd = toUsd(raw.networkFee.amount, networkFeeAssetDecimals, feePriceUsd); + + const quoteId = `q_${generateUlid()}`; + const expiresIn = QUOTE_TTL_SECONDS; + const expiresAt = Date.now() + expiresIn * 1000; + + const preview = { + quoteId, + expiresIn, + expiresAt, + chain, + + amountIn: raw.amountIn, + amountInFormatted: fmtUnits(raw.amountIn, raw.fromDecimals), + amountInUsd: toUsd(raw.amountIn, raw.fromDecimals, fromPriceUsd), + + expectedOut: raw.expectedOut, + expectedOutFormatted: fmtUnits(raw.expectedOut, raw.toDecimals), + expectedOutUsd: toUsd(raw.expectedOut, raw.toDecimals, toPriceUsd), + + minOut: raw.minOut, + minOutFormatted: fmtUnits(raw.minOut, raw.toDecimals), + + slippageBps: raw.slippageBps, + priceImpactPct: (raw as QuoteSolResult).priceImpactPct ?? null, + + fees: { + network: { + asset: raw.networkFee.asset, + amount: raw.networkFee.amount, + amountFormatted: fmtUnits(raw.networkFee.amount, networkFeeAssetDecimals), + amountUsd: networkFeeUsd, + }, + // dex fee включён в expectedOut (Pancake 0.25%, SunSwap 0.3%+0.7% fee router, Jupiter platform varied). + // Не вычисляем отдельно — слишком много moving parts. + total: { amountUsd: networkFeeUsd }, + }, + + route: raw.route, + approveRequired: raw.approveRequired, + estimatedGasUnits: raw.estimatedGasUnits ?? null, + }; + + const cached: CachedSwapQuote = { + quoteId, + userId, + chain, + createdAt: Date.now(), + expiresAt, + params: cacheParams, + locked: { + expectedOut: raw.expectedOut, + minOut: raw.minOut, + jupiterQuoteResponse: lockedJupiterQuote, + }, + preview, + }; + + const saved = await saveQuote(cached, expiresIn); + if (!saved) { + res.status(503).json({ success: false, error: 'Quote cache unavailable — try again' }); + return; + } + + res.json({ success: true, data: preview }); + } catch (err: any) { + logger.error(`quoteSwap failed for user ${userId} chain ${chain}: ${err.stack || err.message}`); + res.status(502).json({ success: false, error: err?.message?.slice?.(0, 250) || 'Quote failed' }); + } + }, + + /** + * POST /api/wallets/:chain/swap — execute swap с locked params из quote. + * Body: { quoteId } — обязательно. Quote должен быть запрошен через `/swap/quote` ранее (TTL 30s). + * Returns: { approveTxid?, swapTxid | signature }. + * + * Legacy fallback: если body содержит {from, to, amount,...} вместо {quoteId} — + * выполнит execute в режиме re-quote on-chain (без anti-MEV gate). Хранится для + * backwards-compat. Web UI должен идти через 2-step flow. */ async swapOnChain(req: Request, res: Response) { const userId = req.auth!.userId; @@ -686,6 +901,21 @@ export const WalletController = { } } + // Read quote from cache (preferred 2-step path). + const { quoteId } = req.body ?? {}; + let cachedQuote: CachedSwapQuote | null = null; + if (typeof quoteId === 'string' && quoteId.length > 0) { + cachedQuote = await getQuote(userId, quoteId); + if (!cachedQuote) { + res.status(410).json({ success: false, error: 'Quote expired or not found — request a new one via POST /:chain/swap/quote' }); + return; + } + if (cachedQuote.chain !== chain) { + res.status(400).json({ success: false, error: `Quote chain mismatch: quote=${cachedQuote.chain} ≠ url=${chain}` }); + return; + } + } + const releaseLock = await acquireSendLock(userId, chain); let mnemonic: string | null = null; let auditId: string; @@ -706,7 +936,7 @@ export const WalletController = { event: 'wallet.swap', userId, ip: req.ip || null, - meta: { chain, body: req.body }, + meta: { chain, quoteId: cachedQuote?.quoteId, body: cachedQuote ? undefined : req.body }, }); } catch (auditErr: any) { logger.error(`Audit DB INSERT MUST succeed for wallet.swap: ${auditErr.message}`); @@ -719,39 +949,47 @@ export const WalletController = { let result: any; try { if (chain === 'BSC') { - const { from, to, amount, slippageBps, feeTier } = req.body ?? {}; + const params = cachedQuote + ? cachedQuote.params + : req.body ?? {}; + const { from, to, amount, slippageBps, feeTier } = params; if (typeof from !== 'string' || typeof to !== 'string' || typeof amount !== 'string') { - throw new Error('BSC swap body: {from, to, amount} required as strings'); + throw new Error('BSC swap body: {quoteId} OR {from, to, amount} required'); } - result = await swapBsc({ + result = await executeBsc({ mnemonic, expectedFromAddress: wallet.address, from, to, amount, - slippageBps, - feeTier, + slippageBps: typeof slippageBps === 'number' ? slippageBps : undefined, + feeTier: feeTier === 'slow' || feeTier === 'normal' || feeTier === 'fast' ? feeTier : undefined, + lockedMinOut: cachedQuote?.locked.minOut, }); } else if (chain === 'TRX') { - const { from, to, amount, slippageBps } = req.body ?? {}; + const params = cachedQuote ? cachedQuote.params : req.body ?? {}; + const { from, to, amount, slippageBps } = params; if (typeof from !== 'string' || typeof to !== 'string' || typeof amount !== 'string') { - throw new Error('TRX swap body: {from, to, amount} required as strings'); + throw new Error('TRX swap body: {quoteId} OR {from, to, amount} required'); } - result = await swapTrx({ + result = await executeTrx({ mnemonic, expectedFromAddress: wallet.address, from, to, amount, - slippageBps, + slippageBps: typeof slippageBps === 'number' ? slippageBps : undefined, + lockedMinOut: cachedQuote?.locked.minOut, }); } else { // SOL Jupiter - const { inputMint, outputMint, amount, slippageBps } = req.body ?? {}; + const params = cachedQuote ? cachedQuote.params : req.body ?? {}; + const { inputMint, outputMint, amount, slippageBps } = params; if (typeof inputMint !== 'string' || typeof outputMint !== 'string' || typeof amount !== 'string') { - throw new Error('SOL swap body: {inputMint, outputMint, amount} required as strings'); + throw new Error('SOL swap body: {quoteId} OR {inputMint, outputMint, amount} required'); } - result = await swapSol({ + result = await executeSol({ mnemonic, expectedFromAddress: wallet.address, inputMint, outputMint, amount, - slippageBps, + slippageBps: typeof slippageBps === 'number' ? slippageBps : undefined, + jupiterQuoteResponse: cachedQuote?.locked.jupiterQuoteResponse, }); } } catch (swapErr: any) { @@ -759,6 +997,11 @@ export const WalletController = { throw swapErr; } + // Anti-replay: удаляем quote после успешного execute. + if (cachedQuote) { + deleteQuote(userId, cachedQuote.quoteId).catch(() => {}); + } + await completeAudit(auditId, 'success', result); res.json({ success: true, data: { chain, ...result } }); } catch (err: any) { diff --git a/apps/api/src/lib/swap-quote-cache.ts b/apps/api/src/lib/swap-quote-cache.ts new file mode 100644 index 0000000..650d064 --- /dev/null +++ b/apps/api/src/lib/swap-quote-cache.ts @@ -0,0 +1,146 @@ +/** + * Swap quote cache (KeyDB). + * + * Используется 2-step swap flow: `quoteSwap` сохраняет результат расчёта в Redis + * под опаковым `quoteId`, `swapOnChain` читает по `{userId, quoteId}` и выполняет + * swap с зафиксированными параметрами (anti-MEV защита от изменения minOut между + * quote и execute). + * + * Cache key: + * swap-quote:{userId}:{quoteId} + * + * TTL: 30 секунд (default). После expire — execute вернёт 410 Gone и юзер + * перезапросит quote. + * + * Anti-replay: успешный execute удаляет cache entry (см. `deleteQuote`). + * + * Security: + * - quoteId сгенерирован через ULID (collision-resistant) + * - cache key включает userId — even if quoteId leak'нет, другой юзер не + * сможет execute (DB read будет miss) + * - extra check на field `userId` внутри cached object — defence-in-depth + */ + +import { getRedis } from '../config/redis'; +import { logger } from './logger'; + +const KEY_PREFIX = 'swap-quote:'; +const DEFAULT_TTL_SECONDS = 30; + +export interface CachedSwapQuote { + // Метаданные + quoteId: string; + userId: string; + chain: 'BSC' | 'TRX' | 'SOL'; + createdAt: number; // unix ms + expiresAt: number; // unix ms + + // Параметры execute — locked + // Для BSC/TRX: from/to/amount — symbols. SOL: inputMint/outputMint/amount — mints. + params: { + from?: string; + to?: string; + inputMint?: string; + outputMint?: string; + amount: string; + slippageBps: number; + feeTier?: 'slow' | 'normal' | 'fast'; + }; + + // Locked-in expectation для execute path (защита от MEV-sandwich). + // На execute мы передаём `minOut` (BSC/TRX) ИЛИ напрямую `quoteResponse` + // (SOL — Jupiter API требует full quote object). + locked: { + expectedOut: string; + minOut: string; + // SOL only: serialized Jupiter /quote response (для re-use на /swap step). + jupiterQuoteResponse?: any; + }; + + // Snapshot всех полей quote — возвращается клиенту, попадает в audit_log. + preview: any; +} + +function buildKey(userId: string, quoteId: string): string { + return `${KEY_PREFIX}${userId}:${quoteId}`; +} + +/** + * Сохраняет quote в KeyDB с TTL. + * Возвращает true если успешно, false если cache write failed (не критично — caller + * может вернуть 503). + */ +export async function saveQuote( + quote: CachedSwapQuote, + ttlSeconds: number = DEFAULT_TTL_SECONDS, +): Promise { + if (ttlSeconds < 1 || ttlSeconds > 600) { + throw new Error(`saveQuote: ttlSeconds ${ttlSeconds} out of range [1,600]`); + } + try { + const key = buildKey(quote.userId, quote.quoteId); + await getRedis().set(key, JSON.stringify(quote), 'EX', ttlSeconds); + return true; + } catch (err: any) { + logger.error(`swap-quote-cache.saveQuote failed: ${err.message}`); + return false; + } +} + +/** + * Читает quote из KeyDB по `{userId, quoteId}`. + * Возвращает `null` если: + * - не найден (expired или never existed) + * - userId mismatch в cached object (defence-in-depth) + * - JSON parse error + * - Redis unavailable (логируется error) + */ +export async function getQuote( + userId: string, + quoteId: string, +): Promise { + if (!userId || !quoteId) return null; + // Базовая sanity-check: quoteId должен быть alphanumeric (ULID = 26 chars), но + // допускаем любые printable для гибкости. Бьём только entrants с обвидно + // malformed input. + if (quoteId.length > 64 || /[\r\n\s]/.test(quoteId)) return null; + + try { + const key = buildKey(userId, quoteId); + const raw = await getRedis().get(key); + if (!raw) return null; + + let parsed: CachedSwapQuote; + try { + parsed = JSON.parse(raw) as CachedSwapQuote; + } catch { + logger.error(`swap-quote-cache.getQuote: JSON parse failed for key=${key}`); + return null; + } + + // Defence-in-depth: cache key уже content-binds userId, но проверяем поле. + if (parsed.userId !== userId) { + logger.error(`swap-quote-cache.getQuote: userId mismatch (key=${userId}, body=${parsed.userId})`); + return null; + } + + return parsed; + } catch (err: any) { + logger.error(`swap-quote-cache.getQuote failed: ${err.message}`); + return null; + } +} + +/** + * Удаляет quote после успешного execute (anti-replay). + * Best-effort — ошибки логируются и swallowed. + */ +export async function deleteQuote(userId: string, quoteId: string): Promise { + try { + await getRedis().del(buildKey(userId, quoteId)); + } catch (err: any) { + logger.warn(`swap-quote-cache.deleteQuote failed: ${err.message}`); + } +} + +export const QUOTE_TTL_SECONDS = DEFAULT_TTL_SECONDS; diff --git a/apps/api/src/routes/bsc-swap-proxy.routes.ts b/apps/api/src/routes/bsc-swap-proxy.routes.ts deleted file mode 100644 index e01f0d4..0000000 --- a/apps/api/src/routes/bsc-swap-proxy.routes.ts +++ /dev/null @@ -1,218 +0,0 @@ -import { Request, Response, Router } from 'express'; -import { ethers } from 'ethers'; -import { logger } from '../lib/logger'; -import { assertUserOwnsAddress } from '../lib/wallet-binding'; - -const router = Router(); - -const BSC_RPC = 'https://bsc-dataseed.binance.org'; -const BSC_CHAIN_ID = 56; -const BSC_TIMEOUT_MS = 15_000; - -// PancakeSwap V2 Router -const PANCAKE_ROUTER = '0x10ED43C718714eb63d5aA57B78B54704E256024E'; -const WBNB = '0xbb4CdB9CBd36B01bD1cBaEBF2De08d9173bc095c'; - -// Supported tokens -const TOKEN_MAP: Record = { - BNB: WBNB, - USDT: '0x55d398326f99059fF775485246999027B3197955', - DOGE: '0xbA2aE424d960c26247Dd6c32edC70B295c744C43', -}; - -const TOKEN_DECIMALS: Record = { - BNB: 18, - USDT: 18, - DOGE: 8, -}; - -const ROUTER_ABI = [ - 'function getAmountsOut(uint amountIn, address[] calldata path) external view returns (uint[] memory amounts)', - 'function swapExactETHForTokensSupportingFeeOnTransferTokens(uint amountOutMin, address[] calldata path, address to, uint deadline) external payable', - 'function swapExactTokensForETHSupportingFeeOnTransferTokens(uint amountIn, uint amountOutMin, address[] calldata path, address to, uint deadline) external', -]; - -const ERC20_ABI = [ - 'function approve(address spender, uint256 amount) external returns (bool)', - 'function allowance(address owner, address spender) external view returns (uint256)', -]; - -router.get('/quote', getSwapQuote); -router.post('/build', buildSwapTx); - -export default router; - -// ─── GET /quote ─── - -async function getSwapQuote(req: Request, res: Response) { - const from = String(req.query.from || '').toUpperCase(); - const to = String(req.query.to || '').toUpperCase(); - const amount = String(req.query.amount || ''); - - if (!TOKEN_MAP[from] || !TOKEN_MAP[to]) { - res.status(400).json({ success: false, error: 'Invalid from/to. Supported: BNB, USDT, DOGE' }); - return; - } - if (from === to) { - res.status(400).json({ success: false, error: 'from and to must be different' }); - return; - } - - const amountBigInt = BigInt(amount || '0'); - if (amountBigInt <= 0n) { - res.status(400).json({ success: false, error: 'amount must be positive' }); - return; - } - - try { - const provider = new ethers.providers.StaticJsonRpcProvider(BSC_RPC, BSC_CHAIN_ID); - const routerContract = new ethers.Contract(PANCAKE_ROUTER, ROUTER_ABI, provider); - - const path = [TOKEN_MAP[from], TOKEN_MAP[to]]; - const amounts: ethers.BigNumber[] = await withTimeout( - routerContract.getAmountsOut(amount, path), - BSC_TIMEOUT_MS, - 'PancakeSwap quote timed out' - ); - - const amountOut = amounts[amounts.length - 1].toString(); - - res.json({ - success: true, - amountIn: amountBigInt.toString(), - amountOut, - from, - to, - fromDecimals: TOKEN_DECIMALS[from], - toDecimals: TOKEN_DECIMALS[to], - }); - } catch (error) { - logger.error(`BSC quote failed: ${(error as any)?.stack || (error as any)?.message}`); - res.status(502).json({ success: false, error: 'Failed to get BSC swap quote' }); - } -} - -// ─── POST /build ─── - -async function buildSwapTx(req: Request, res: Response) { - const { from, to, amount, amountOutMin, userAddress } = req.body; - - if (!from || !to || !amount || !amountOutMin || !userAddress) { - res.status(400).json({ success: false, error: 'Missing required fields: from, to, amount, amountOutMin, userAddress' }); - return; - } - - const fromUpper = String(from).toUpperCase(); - const toUpper = String(to).toUpperCase(); - - if (!TOKEN_MAP[fromUpper] || !TOKEN_MAP[toUpper] || fromUpper === toUpper) { - res.status(400).json({ success: false, error: 'Invalid from/to pair' }); - return; - } - - if (!ethers.utils.isAddress(userAddress)) { - res.status(400).json({ success: false, error: 'Invalid BSC address' }); - return; - } - - // C17 — bind userAddress to JWT. Иначе attacker может set userAddress=victim_addr - // и output swap'а пойдёт victim'у/контракту-attacker'у (reentrancy vector). - const userId = req.auth!.userId; - try { - await assertUserOwnsAddress(userId, 'BSC', userAddress); - } catch (err: any) { - res.status(403).json({ success: false, error: err.message }); - return; - } - - // Validate amount + amountOutMin: positive integers > 0 (string-encoded BigInt). - // "0" truthy bypass — без этого attacker может выставить amountOutMin=0 = 100% slippage - // → sandwich attack осушает swap. - if (!/^\d+$/.test(String(amount)) || BigInt(amount) <= 0n) { - res.status(400).json({ success: false, error: 'Invalid amount (must be positive integer string)' }); - return; - } - if (!/^\d+$/.test(String(amountOutMin)) || BigInt(amountOutMin) <= 0n) { - res.status(400).json({ success: false, error: 'Invalid amountOutMin (must be positive; 0 = unlimited slippage)' }); - return; - } - - try { - const provider = new ethers.providers.StaticJsonRpcProvider(BSC_RPC, BSC_CHAIN_ID); - const routerContract = new ethers.Contract(PANCAKE_ROUTER, ROUTER_ABI, provider); - const deadline = Math.floor(Date.now() / 1000) + 1200; // 20 minutes - const path = [TOKEN_MAP[fromUpper], TOKEN_MAP[toUpper]]; - - const transactions: Array<{ type: string; to: string; data: string; value: string }> = []; - - if (fromUpper === 'BNB') { - // BNB → Token: swapExactETHForTokensSupportingFeeOnTransferTokens - const data = routerContract.interface.encodeFunctionData( - 'swapExactETHForTokensSupportingFeeOnTransferTokens', - [amountOutMin, path, userAddress, deadline] - ); - - transactions.push({ - type: 'swap', - to: PANCAKE_ROUTER, - data, - value: amount, // BNB amount in wei - }); - } else { - // Token → BNB: check allowance, build approve if needed, then swap - const tokenAddress = TOKEN_MAP[fromUpper]; - const tokenContract = new ethers.Contract(tokenAddress, ERC20_ABI, provider); - const currentAllowance: ethers.BigNumber = await withTimeout( - tokenContract.allowance(userAddress, PANCAKE_ROUTER), - BSC_TIMEOUT_MS, - 'Allowance check timed out' - ); - - if (currentAllowance.lt(ethers.BigNumber.from(amount))) { - // Exact-amount approve (НЕ MaxUint256!). Infinite approval = post-tx attack vector: - // если router compromised или attacker узнаёт private key позже, attacker дренит - // всё что approved. Approve только то что нужно сейчас. - const approveData = tokenContract.interface.encodeFunctionData( - 'approve', - [PANCAKE_ROUTER, amount] - ); - - transactions.push({ - type: 'approve', - to: tokenAddress, - data: approveData, - value: '0', - }); - } - - // Build swap tx - const swapData = routerContract.interface.encodeFunctionData( - 'swapExactTokensForETHSupportingFeeOnTransferTokens', - [amount, amountOutMin, path, userAddress, deadline] - ); - - transactions.push({ - type: 'swap', - to: PANCAKE_ROUTER, - data: swapData, - value: '0', - }); - } - - res.json({ success: true, transactions }); - } catch (error) { - logger.error(`BSC build failed: ${(error as any)?.stack || (error as any)?.message}`); - res.status(502).json({ success: false, error: 'Failed to build BSC swap' }); - } -} - -// ─── Utils ─── - -function withTimeout(promise: Promise, timeoutMs: number, message: string): Promise { - return new Promise((resolve, reject) => { - const timeoutId = setTimeout(() => reject(new Error(message)), timeoutMs); - promise - .then((value) => { clearTimeout(timeoutId); resolve(value); }) - .catch((error) => { clearTimeout(timeoutId); reject(error); }); - }); -} diff --git a/apps/api/src/routes/sol-swap-proxy.routes.ts b/apps/api/src/routes/sol-swap-proxy.routes.ts deleted file mode 100644 index 609607e..0000000 --- a/apps/api/src/routes/sol-swap-proxy.routes.ts +++ /dev/null @@ -1,210 +0,0 @@ -import { Request, Response, Router } from 'express'; -import { env } from '../config/env'; -import { logger } from '../lib/logger'; -import { assertUserOwnsAddress } from '../lib/wallet-binding'; -import { PublicKey } from '@solana/web3.js'; - -const router = Router(); -const JUPITER_BASE = 'https://api.jup.ag/swap/v1'; -const JUPITER_TIMEOUT_MS = 15_000; - -const ALLOWED_MINTS = new Set([ - 'So11111111111111111111111111111111111111112', // SOL - 'Es9vMFrzaCERmJfrF4H2FYD4KCoNkY11McCe8BenwNYB', // USDT - 'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v', // USDC - 'pumpCmXqMfrsAkQ5r49WcJnRayYRqmXz6ae8H7H9Dfn', // PUMP - 'JUPyiwrYJFskUPiHa7hkeR8VUtAeFoSYbKedZNsDvCN', // JUP - 'EKpQGSJtjMFqKZ9KQanSqYXRcF8fBopzLHYxdM65zcjm', // WIF - '7GCihgDB8fe6KNjn2MYtkzZcRjQy3t9GHdC8uHYmW2hr', // POPCAT - '6p6xgHyF7AeE6TZkSmFsko444wqoP15icUSqi2jfGiPN', // TRUMP - 'HZ1JovNiVvGrGNiiYvEozEVgZ58xaU3RKwX8eACQBCt3', // PYTH - 'jtojtomepa8beP8AuQc6eXt5FriJwfFMwQx2v2f9mCL', // JTO - '85VBFQZC9TZkfaptBWjvUw7YbZjy52A6mjtPGjstQAmQ', // W - 'DezXAZ8z7PnrnRJjz3wXBoRgixCa6xjnB7YaB1pPB263', // BONK - 'orcaEKTdK7LKz57vaAYr9QeNsVEPfiu6QeMU1kektZE', // ORCA - '2zMMhcVQEXDtdE6vsFS7S7D5oUodfJHE8vd1gnBouauv', // PENGU - '4k3Dyjzvzp8eMZWUXbBCjEvwSkkk59S5iCNLY3QrkX6R', // RAY -]); - -router.get('/quote', getQuote); -router.post('/build', buildSwap); - -export default router; - -/** - * GET /api/sol/swap/quote - * Proxies to Jupiter GET /v6/quote - */ -async function getQuote(req: Request, res: Response) { - const { inputMint, outputMint, amount, slippageBps } = req.query; - - if (!inputMint || !outputMint || !amount || !slippageBps) { - res.status(400).json({ success: false, error: 'Missing required params: inputMint, outputMint, amount, slippageBps' }); - return; - } - - if (!ALLOWED_MINTS.has(String(inputMint)) || !ALLOWED_MINTS.has(String(outputMint))) { - res.status(400).json({ success: false, error: 'Token mint not in whitelist' }); - return; - } - - if (inputMint === outputMint) { - res.status(400).json({ success: false, error: 'inputMint and outputMint must be different' }); - return; - } - - const parsedAmount = parseInt(String(amount), 10); - if (!Number.isFinite(parsedAmount) || parsedAmount <= 0) { - res.status(400).json({ success: false, error: 'amount must be a positive integer' }); - return; - } - - const parsedSlippage = parseInt(String(slippageBps), 10); - if (!Number.isFinite(parsedSlippage) || parsedSlippage < 1 || parsedSlippage > 500) { - res.status(400).json({ success: false, error: 'slippageBps must be 1-500 (max 5%)' }); - return; - } - - const controller = new AbortController(); - const timeout = setTimeout(() => controller.abort(), JUPITER_TIMEOUT_MS); - - try { - const url = new URL(`${JUPITER_BASE}/quote`); - url.searchParams.set('inputMint', String(inputMint)); - url.searchParams.set('outputMint', String(outputMint)); - url.searchParams.set('amount', String(parsedAmount)); - // H38 — forward PARSED value, not raw query string ("300abc" → "300" но raw forwarded as "300abc") - url.searchParams.set('slippageBps', String(parsedSlippage)); - - // Platform fee (0.7%) — Jupiter deducts this natively - if (env.jupiterFeeBps > 0) { - url.searchParams.set('platformFeeBps', String(env.jupiterFeeBps)); - } - - const headers: Record = { Accept: 'application/json' }; - if (env.jupiterApiKey) { - headers['x-api-key'] = env.jupiterApiKey; - } - - const response = await fetch(url.toString(), { headers, signal: controller.signal }); - - if (!response.ok) { - const text = await response.text().catch(() => ''); - // НЕ reflect'им upstream body — может содержать internal config (feeAccount, refs) - logger.error(`Jupiter quote ${response.status}: ${text.slice(0, 200)}`); - res.status(502).json({ success: false, error: 'Jupiter upstream error' }); - return; - } - - const data = await response.json(); - res.json(data); - } catch (error) { - if (controller.signal.aborted) { - res.status(504).json({ success: false, error: 'Jupiter quote request timed out' }); - return; - } - res.status(502).json({ success: false, error: 'Failed to reach Jupiter API' }); - } finally { - clearTimeout(timeout); - } -} - -/** - * POST /api/sol/swap/build - * Proxies to Jupiter POST /v6/swap — returns serialized transaction for client signing - */ -async function buildSwap(req: Request, res: Response) { - const { quoteResponse, userPublicKey } = req.body; - - if (!quoteResponse || typeof quoteResponse !== 'object') { - res.status(400).json({ success: false, error: 'Missing quoteResponse object' }); - return; - } - - if (!userPublicKey || typeof userPublicKey !== 'string') { - res.status(400).json({ success: false, error: 'Missing userPublicKey string' }); - return; - } - - // Validate userPublicKey syntactically - try { - new PublicKey(userPublicKey); - } catch { - res.status(400).json({ success: false, error: 'Invalid userPublicKey (not a valid base58 32-byte pubkey)' }); - return; - } - - // C9 — bind userPublicKey to JWT (без bind: feeAccount/referral hijacking, ATA pre-staging) - const userId = req.auth!.userId; - try { - await assertUserOwnsAddress(userId, 'SOL', userPublicKey); - } catch (err: any) { - res.status(403).json({ success: false, error: err.message }); - return; - } - - // C8 — re-validate input/output mints в quoteResponse против ALLOWED_MINTS. - // Иначе attacker может вызвать /quote с whitelisted mints (passes), затем POST /build - // с hand-crafted quoteResponse где outputMint = scam-mint, и Jupiter trust'нет shape. - const qInputMint = (quoteResponse as any)?.inputMint; - const qOutputMint = (quoteResponse as any)?.outputMint; - if (typeof qInputMint !== 'string' || !ALLOWED_MINTS.has(qInputMint)) { - res.status(400).json({ success: false, error: 'quoteResponse.inputMint not in allowlist' }); - return; - } - if (typeof qOutputMint !== 'string' || !ALLOWED_MINTS.has(qOutputMint)) { - res.status(400).json({ success: false, error: 'quoteResponse.outputMint not in allowlist' }); - return; - } - - const controller = new AbortController(); - const timeout = setTimeout(() => controller.abort(), JUPITER_TIMEOUT_MS); - - try { - const headers: Record = { - 'Content-Type': 'application/json', - Accept: 'application/json', - }; - if (env.jupiterApiKey) { - headers['x-api-key'] = env.jupiterApiKey; - } - - const swapBody: Record = { - quoteResponse, - userPublicKey, - wrapAndUnwrapSol: true, - dynamicComputeUnitLimit: true, - prioritizationFeeLamports: 'auto', - }; - - // Attach referral fee account for Jupiter to route platform fees - if (env.jupiterReferralAccount) { - swapBody.feeAccount = env.jupiterReferralAccount; - } - - const response = await fetch(`${JUPITER_BASE}/swap`, { - method: 'POST', - headers, - signal: controller.signal, - body: JSON.stringify(swapBody), - }); - - if (!response.ok) { - const text = await response.text().catch(() => ''); - logger.error(`Jupiter swap build ${response.status}: ${text.slice(0, 200)}`); - res.status(502).json({ success: false, error: 'Jupiter upstream error' }); - return; - } - - const data = await response.json(); - res.json(data); - } catch (error) { - if (controller.signal.aborted) { - res.status(504).json({ success: false, error: 'Jupiter swap build timed out' }); - return; - } - res.status(502).json({ success: false, error: 'Failed to reach Jupiter API' }); - } finally { - clearTimeout(timeout); - } -} diff --git a/apps/api/src/routes/tron-swap-proxy.routes.ts b/apps/api/src/routes/tron-swap-proxy.routes.ts deleted file mode 100644 index e383d1b..0000000 --- a/apps/api/src/routes/tron-swap-proxy.routes.ts +++ /dev/null @@ -1,499 +0,0 @@ -import { Request, Response, Router } from 'express'; -import { env } from '../config/env'; -import { logger } from '../lib/logger'; -import { assertUserOwnsAddress } from '../lib/wallet-binding'; - -const router = Router(); -const TRONGRID_BASE = 'https://api.trongrid.io'; -const TRON_TIMEOUT_MS = 15_000; -const TRON_ADDRESS_RE = /^T[1-9A-HJ-NP-Za-km-z]{33}$/; - -// Contracts -const SUNSWAP_SMART_ROUTER = 'TKzxdSv2FZKQrEqkKVgp5DcwEXBEKMg2Ax'; -const USDT_CONTRACT = 'TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t'; -const WTRX_CONTRACT = 'TNUC9Qb1rRpS5CbWLmNMxXBjyFoydXjWFR'; - -// FeeSwapRouter_TRX — deployed contract, 0.7% fee -const FEE_SWAP_ROUTER_TRX = 'TX8E6X7X1FWYRYuYR2LTvS7zm1KchcVs5E'; -const FEE_BPS = 70n; -const BPS_DENOMINATOR = 10_000n; - -const BASE58_ALPHABET = '123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz'; - -// Token map -const TOKEN_MAP: Record = { - TRX: WTRX_CONTRACT, - USDT: USDT_CONTRACT, -}; - -const TOKEN_DECIMALS: Record = { - TRX: 6, - USDT: 6, -}; - -router.get('/quote', getSwapQuote); -router.post('/build', buildSwapTx); -router.post('/broadcast', broadcastTx); - -export default router; - -// ─── Helpers ─── - -function tronAddressToHex(address: string): string { - let num = 0n; - for (const char of address) { - const index = BASE58_ALPHABET.indexOf(char); - if (index === -1) throw new Error('Invalid base58 character'); - num = num * 58n + BigInt(index); - } - const hex = num.toString(16).padStart(50, '0'); - return hex.slice(2, 42); // skip 0x41, take 20 bytes -} - -function encodeUint256(value: bigint): string { - return value.toString(16).padStart(64, '0'); -} - -function encodeAddress(tronAddress: string): string { - const hex = tronAddressToHex(tronAddress); - return hex.padStart(64, '0'); -} - -function tronHeaders(): Record { - const headers: Record = { - 'Content-Type': 'application/json', - Accept: 'application/json', - }; - if (env.tronApiKey) { - headers['TRON-PRO-API-KEY'] = env.tronApiKey; - } - return headers; -} - -// Encode bytes calldata as ABI dynamic bytes parameter -function encodeDynamicBytes(hexData: string): string { - // Remove 0x prefix if present - const data = hexData.startsWith('0x') ? hexData.slice(2) : hexData; - const byteLength = data.length / 2; - const lengthEncoded = encodeUint256(BigInt(byteLength)); - // Pad data to 32-byte boundary - const paddedData = data.padEnd(Math.ceil(data.length / 64) * 64, '0'); - return lengthEncoded + paddedData; -} - -// ─── GET /quote ─── - -async function getSwapQuote(req: Request, res: Response) { - const from = String(req.query.from || '').toUpperCase(); - const to = String(req.query.to || '').toUpperCase(); - const amount = String(req.query.amount || ''); - - if (!TOKEN_MAP[from] || !TOKEN_MAP[to]) { - res.status(400).json({ success: false, error: 'Invalid from/to. Use TRX or USDT' }); - return; - } - if (from === to) { - res.status(400).json({ success: false, error: 'from and to must be different' }); - return; - } - - const amountBigInt = BigInt(amount || '0'); - if (amountBigInt <= 0n) { - res.status(400).json({ success: false, error: 'amount must be positive' }); - return; - } - - // Deduct 0.7% fee — SunSwap will only receive 99.3% - const feeAmount = (amountBigInt * FEE_BPS) / BPS_DENOMINATOR; - const amountAfterFee = amountBigInt - feeAmount; - - const controller = new AbortController(); - const timeout = setTimeout(() => controller.abort(), TRON_TIMEOUT_MS); - - try { - const fromToken = TOKEN_MAP[from]; - const toToken = TOKEN_MAP[to]; - - // ABI: getAmountsOut(uint256 amountIn, address[] path) - const amountHex = encodeUint256(amountAfterFee); - const offsetHex = encodeUint256(64n); - const lengthHex = encodeUint256(2n); - const addr0Hex = encodeAddress(fromToken); - const addr1Hex = encodeAddress(toToken); - const parameter = amountHex + offsetHex + lengthHex + addr0Hex + addr1Hex; - - const response = await fetch(`${TRONGRID_BASE}/wallet/triggerconstantcontract`, { - method: 'POST', - headers: tronHeaders(), - signal: controller.signal, - body: JSON.stringify({ - owner_address: SUNSWAP_SMART_ROUTER, - contract_address: SUNSWAP_SMART_ROUTER, - function_selector: 'getAmountsOut(uint256,address[])', - parameter, - visible: true, - }), - }); - - if (!response.ok) { - res.status(response.status).json({ success: false, error: 'TronGrid error' }); - return; - } - - const body = (await response.json()) as { - constant_result?: string[]; - result?: { result?: boolean; message?: string }; - }; - - if (!body.constant_result?.[0]) { - const errorMsg = body.result?.message - ? Buffer.from(body.result.message, 'hex').toString('utf8') - : 'No result from getAmountsOut'; - res.status(502).json({ success: false, error: errorMsg }); - return; - } - - const resultHex = body.constant_result[0]; - const amountOutHex = resultHex.slice(-64); - const amountOut = BigInt('0x' + amountOutHex).toString(); - - res.json({ - success: true, - amountIn: amountBigInt.toString(), - amountOut, - fee: feeAmount.toString(), - from, - to, - fromDecimals: TOKEN_DECIMALS[from], - toDecimals: TOKEN_DECIMALS[to], - }); - } catch (error) { - if (controller.signal.aborted) { - res.status(504).json({ success: false, error: 'TronGrid quote request timed out' }); - return; - } - res.status(502).json({ success: false, error: 'Failed to reach TronGrid' }); - } finally { - clearTimeout(timeout); - } -} - -// ─── POST /build ─── - -async function buildSwapTx(req: Request, res: Response) { - const { from, to, amount, amountOutMin, userAddress } = req.body; - - if (!from || !to || !amount || !amountOutMin || !userAddress) { - res.status(400).json({ success: false, error: 'Missing required fields: from, to, amount, amountOutMin, userAddress' }); - return; - } - - const fromUpper = String(from).toUpperCase(); - const toUpper = String(to).toUpperCase(); - - if (!TOKEN_MAP[fromUpper] || !TOKEN_MAP[toUpper] || fromUpper === toUpper) { - res.status(400).json({ success: false, error: 'Invalid from/to pair' }); - return; - } - - if (!TRON_ADDRESS_RE.test(userAddress)) { - res.status(400).json({ success: false, error: 'Invalid TRON address' }); - return; - } - - // H47 — bind userAddress to JWT (output идёт на userAddress; без bind output к attacker'у) - const userId = req.auth!.userId; - try { - await assertUserOwnsAddress(userId, 'TRX', userAddress); - } catch (err: any) { - res.status(403).json({ success: false, error: err.message }); - return; - } - - const controller = new AbortController(); - const timeout = setTimeout(() => controller.abort(), TRON_TIMEOUT_MS); - - try { - const transactions: Array<{ txID: string; raw_data: unknown; raw_data_hex: string; type: string }> = []; - const amountBigInt = BigInt(amount); - const minOutBigInt = BigInt(amountOutMin); - const deadline = BigInt(Math.floor(Date.now() / 1000) + 1200); // 20 minutes - - // Calculate fee and swap amounts - const feeAmount = (amountBigInt * FEE_BPS) / BPS_DENOMINATOR; - const swapAmount = amountBigInt - feeAmount; - - if (fromUpper === 'TRX') { - // ═══ TRX → USDT: through FeeSwapRouter.swapNativeWithFee(bytes routerCalldata) ═══ - - // Step 1: Build the SunSwap calldata for swapExactETHForTokens - // SunSwap will receive swapAmount (99.3%) of TRX from FeeSwapRouter - // SunSwap sends output tokens to `to` address — must be userAddress - const sunswapCalldata = buildSwapExactETHForTokensCalldata( - minOutBigInt, - [WTRX_CONTRACT, USDT_CONTRACT], - userAddress, - deadline, - ); - - // Step 2: Wrap in swapNativeWithFee(bytes routerCalldata) - // ABI: swapNativeWithFee(bytes) — single dynamic bytes param - const offsetToBytes = encodeUint256(32n); // offset to dynamic bytes - const feeRouterParam = offsetToBytes + encodeDynamicBytes(sunswapCalldata); - - const swapTx = await buildTriggerSmartContract({ - ownerAddress: userAddress, - contractAddress: FEE_SWAP_ROUTER_TRX, - functionSelector: 'swapNativeWithFee(bytes)', - parameter: feeRouterParam, - callValue: Number(amountBigInt), // full amount — contract takes 0.7% - feeLimit: 200_000_000, // 200 TRX - signal: controller.signal, - }); - - if (swapTx) { - transactions.push({ ...swapTx, type: 'swap' }); - } - - } else { - // ═══ USDT → TRX: through FeeSwapRouter.swapTokenWithFee(address, uint256, bytes) ═══ - - // Step 1: Approve USDT to FeeSwapRouter (not SunSwap!) - const allowance = await checkAllowance(userAddress, USDT_CONTRACT, FEE_SWAP_ROUTER_TRX, controller.signal); - - if (allowance < amountBigInt) { - const approveTx = await buildTriggerSmartContract({ - ownerAddress: userAddress, - contractAddress: USDT_CONTRACT, - functionSelector: 'approve(address,uint256)', - parameter: encodeAddress(FEE_SWAP_ROUTER_TRX) + encodeUint256(BigInt('0xffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff')), - callValue: 0, - feeLimit: 100_000_000, - signal: controller.signal, - }); - - if (approveTx) { - transactions.push({ ...approveTx, type: 'approve' }); - } - } - - // Step 2: Build SunSwap calldata for swapExactTokensForETH - // FeeSwapRouter will approve swapAmount (99.3%) to SunSwap and forward this calldata - const sunswapCalldata = buildSwapExactTokensForETHCalldata( - swapAmount, // 99.3% — what SunSwap actually receives - minOutBigInt, - [USDT_CONTRACT, WTRX_CONTRACT], - userAddress, // output TRX goes to user - deadline, - ); - - // Step 3: Wrap in swapTokenWithFee(address tokenIn, uint256 amountIn, bytes routerCalldata) - const tokenInEncoded = encodeAddress(USDT_CONTRACT); - const amountInEncoded = encodeUint256(amountBigInt); // full amount — contract takes 0.7% - const offsetToBytes = encodeUint256(96n); // offset to dynamic bytes (3 * 32) - const feeRouterParam = tokenInEncoded + amountInEncoded + offsetToBytes + encodeDynamicBytes(sunswapCalldata); - - const swapTx = await buildTriggerSmartContract({ - ownerAddress: userAddress, - contractAddress: FEE_SWAP_ROUTER_TRX, - functionSelector: 'swapTokenWithFee(address,uint256,bytes)', - parameter: feeRouterParam, - callValue: 0, - feeLimit: 200_000_000, - signal: controller.signal, - }); - - if (swapTx) { - transactions.push({ ...swapTx, type: 'swap' }); - } - } - - if (!transactions.length) { - res.status(502).json({ success: false, error: 'Failed to build swap transactions' }); - return; - } - - res.json({ success: true, transactions }); - } catch (error) { - if (controller.signal.aborted) { - res.status(504).json({ success: false, error: 'Build request timed out' }); - return; - } - logger.error(`TRON swap build failed: ${(error as any)?.stack || (error as any)?.message}`); - res.status(502).json({ success: false, error: 'Failed to build swap' }); - } finally { - clearTimeout(timeout); - } -} - -// ─── POST /broadcast ─── - -async function broadcastTx(req: Request, res: Response) { - const { signedTransaction } = req.body; - - if (!signedTransaction || typeof signedTransaction !== 'object') { - res.status(400).json({ success: false, error: 'Missing or invalid signedTransaction' }); - return; - } - - // C6 — verify owner_address внутри signed tx равен JWT-bound TRX-адресу юзера. - // Иначе endpoint = open relay для arbitrary tx (replay leaked txs, evade IP bans). - const userId = req.auth!.userId; - const contract0 = signedTransaction?.raw_data?.contract?.[0]; - const ownerAddr = contract0?.parameter?.value?.owner_address; - if (typeof ownerAddr !== 'string' || !ownerAddr) { - res.status(400).json({ success: false, error: 'Cannot extract owner_address from signedTransaction.raw_data.contract[0]' }); - return; - } - try { - await assertUserOwnsAddress(userId, 'TRX', ownerAddr); - } catch (err: any) { - logger.warn(`broadcast rejected: ${err.message} userId=${userId}`); - res.status(403).json({ success: false, error: err.message }); - return; - } - - const controller = new AbortController(); - const timeout = setTimeout(() => controller.abort(), TRON_TIMEOUT_MS); - - try { - const response = await fetch(`${TRONGRID_BASE}/wallet/broadcasttransaction`, { - method: 'POST', - headers: tronHeaders(), - signal: controller.signal, - body: JSON.stringify(signedTransaction), - }); - - const data = await response.json(); - res.status(response.ok ? 200 : 502).json(data); - } catch (error) { - if (controller.signal.aborted) { - res.status(504).json({ success: false, error: 'Broadcast timed out' }); - return; - } - res.status(502).json({ success: false, error: 'Failed to broadcast transaction' }); - } finally { - clearTimeout(timeout); - } -} - -// ─── SunSwap Calldata Builders ─── - -// Build raw calldata hex for swapExactETHForTokens(uint256,address[],address,uint256) -function buildSwapExactETHForTokensCalldata( - amountOutMin: bigint, - path: string[], // TRON base58 addresses - to: string, // TRON base58 address - deadline: bigint, -): string { - // Function selector: keccak256("swapExactETHForTokens(uint256,address[],address,uint256)") first 4 bytes - const selector = 'b6f9de95'; - - const amountOutMinEnc = encodeUint256(amountOutMin); - const offsetToPath = encodeUint256(128n); // 4 * 32 bytes offset - const toEnc = encodeAddress(to); - const deadlineEnc = encodeUint256(deadline); - const pathLenEnc = encodeUint256(BigInt(path.length)); - const pathElements = path.map((addr) => encodeAddress(addr)).join(''); - - return selector + amountOutMinEnc + offsetToPath + toEnc + deadlineEnc + pathLenEnc + pathElements; -} - -// Build raw calldata hex for swapExactTokensForETH(uint256,uint256,address[],address,uint256) -function buildSwapExactTokensForETHCalldata( - amountIn: bigint, - amountOutMin: bigint, - path: string[], // TRON base58 addresses - to: string, // TRON base58 address - deadline: bigint, -): string { - // Function selector: keccak256("swapExactTokensForETH(uint256,uint256,address[],address,uint256)") first 4 bytes - const selector = '18cbafe5'; - - const amountInEnc = encodeUint256(amountIn); - const amountOutMinEnc = encodeUint256(amountOutMin); - const offsetToPath = encodeUint256(160n); // 5 * 32 bytes offset - const toEnc = encodeAddress(to); - const deadlineEnc = encodeUint256(deadline); - const pathLenEnc = encodeUint256(BigInt(path.length)); - const pathElements = path.map((addr) => encodeAddress(addr)).join(''); - - return selector + amountInEnc + amountOutMinEnc + offsetToPath + toEnc + deadlineEnc + pathLenEnc + pathElements; -} - -// ─── Internal Helpers ─── - -async function checkAllowance( - owner: string, - tokenContract: string, - spender: string, - signal: AbortSignal -): Promise { - const parameter = encodeAddress(owner) + encodeAddress(spender); - - const response = await fetch(`${TRONGRID_BASE}/wallet/triggerconstantcontract`, { - method: 'POST', - headers: tronHeaders(), - signal, - body: JSON.stringify({ - owner_address: owner, - contract_address: tokenContract, - function_selector: 'allowance(address,address)', - parameter, - visible: true, - }), - }); - - if (!response.ok) return 0n; - - const body = (await response.json()) as { constant_result?: string[] }; - const hex = body.constant_result?.[0]; - if (!hex || /^0+$/.test(hex)) return 0n; - - return BigInt('0x' + hex); -} - -interface TriggerSmartContractParams { - ownerAddress: string; - contractAddress: string; - functionSelector: string; - parameter: string; - callValue: number; - feeLimit: number; - signal: AbortSignal; -} - -async function buildTriggerSmartContract( - params: TriggerSmartContractParams -): Promise<{ txID: string; raw_data: unknown; raw_data_hex: string } | null> { - const response = await fetch(`${TRONGRID_BASE}/wallet/triggersmartcontract`, { - method: 'POST', - headers: tronHeaders(), - signal: params.signal, - body: JSON.stringify({ - owner_address: params.ownerAddress, - contract_address: params.contractAddress, - function_selector: params.functionSelector, - parameter: params.parameter, - call_value: params.callValue, - fee_limit: params.feeLimit, - visible: true, - }), - }); - - if (!response.ok) return null; - - const body = (await response.json()) as { - result?: { result?: boolean; message?: string }; - transaction?: { txID: string; raw_data: unknown; raw_data_hex: string }; - }; - - if (!body.result?.result || !body.transaction) { - const errorMsg = body.result?.message - ? Buffer.from(body.result.message, 'hex').toString('utf8') - : 'Transaction build failed'; - throw new Error(errorMsg); - } - - return body.transaction; -} diff --git a/apps/api/src/routes/wallet.routes.ts b/apps/api/src/routes/wallet.routes.ts index 1800cc3..9e53acc 100644 --- a/apps/api/src/routes/wallet.routes.ts +++ b/apps/api/src/routes/wallet.routes.ts @@ -15,6 +15,9 @@ router.get('/:chain/transactions', WalletController.getChainTransactions); router.get('/:chain/gas-suggestions', WalletController.getGasSuggestions); router.post('/:chain/send', WalletController.sendFromChain); router.post('/:chain/sign-raw-evm-tx', WalletController.signRawEvmTx); +// IMPORTANT: /:chain/swap/quote ДОЛЖЕН быть ПЕРЕД /:chain/swap чтобы Express +// сматчил specific route раньше general'ного. +router.post('/:chain/swap/quote', WalletController.quoteSwap); router.post('/:chain/swap', WalletController.swapOnChain); router.post('/SOL/sign-and-broadcast-tx', WalletController.signSolanaTx); diff --git a/apps/api/src/services/swap-orchestrator.service.ts b/apps/api/src/services/swap-orchestrator.service.ts index db7cc9d..c661ac7 100644 --- a/apps/api/src/services/swap-orchestrator.service.ts +++ b/apps/api/src/services/swap-orchestrator.service.ts @@ -74,6 +74,45 @@ export interface SwapBscParams { feeTier?: FeeTier; } +export interface QuoteBscParams { + fromAddress: string; // derived custodial address (для allowance / estimateGas) + from: string; + to: string; + amount: string; + slippageBps?: number; + feeTier?: FeeTier; +} + +export interface SwapQuoteRaw { + amountIn: string; // smallest units + expectedOut: string; // mid-market quote (smallest units) + minOut: string; // expectedOut × (10000 - slippageBps) / 10000 + slippageBps: number; + route: string[]; // symbol path (info; не используется в execute) + approveRequired: boolean; + estimatedGasUnits?: string; + /** Network fee asset symbol + raw amount (smallest units). */ + networkFee: { + asset: string; + amount: string; + }; + /** Per-token decimals для controller форматирования. */ + fromDecimals: number; + toDecimals: number; +} + +export interface ExecuteBscParams { + mnemonic: string; + expectedFromAddress: string; + from: string; + to: string; + amount: string; + slippageBps?: number; + feeTier?: FeeTier; + /** Locked minOut из quote (anti-MEV). Если undefined — re-quote on-chain (legacy fallback). */ + lockedMinOut?: string; +} + /** Wrapper над `pickProxiedEvmProvider` — все swap-orchestrator EVM calls * идут через OUTBOUND_PROXY_URL если задан. Если не задан — fallback direct. */ async function pickProvider(rpcs: string[], chainId: number): Promise { @@ -87,13 +126,155 @@ function withTimeout(p: Promise, ms: number, msg: string): Promise { ]); } +// BSC decimals map (для quote response). Native BNB = 18. Все BSC tokens из BSC_TOKENS +// = 18 (USDT/USDC/WBNB/BUSD/DOGE — да, на BSC DOGE — 8, остальное 18). Lookup через registry. +import { getEvmTokens } from '../lib/token-registry'; + +function bscTokenDecimals(symbol: string): number { + const upper = symbol.toUpperCase(); + if (upper === 'BNB') return 18; + const t = getEvmTokens('BSC').find((x) => x.symbol.toUpperCase() === upper); + return t?.decimals ?? 18; +} + /** - * BSC chained swap. Если `from` не нативный BNB и allowance < amount — + * BSC quote — read-only расчёт expected output, slippage, gas fee, route. + * НЕ требует mnemonic — все checks через `fromAddress` (custodial address из DB). + * + * Используется в `POST /api/wallets/BSC/swap/quote`. + */ +export async function quoteBsc(p: QuoteBscParams): Promise { + const fromUpper = p.from.toUpperCase(); + const toUpper = p.to.toUpperCase(); + + if (!BSC_TOKEN_MAP[fromUpper] || !BSC_TOKEN_MAP[toUpper] || fromUpper === toUpper) { + throw new Error(`Invalid BSC swap pair: ${fromUpper} → ${toUpper}`); + } + if (!/^\d+$/.test(p.amount) || BigInt(p.amount) <= 0n) { + throw new Error('amount must be positive integer string'); + } + const slippageBps = p.slippageBps ?? 50; + if (slippageBps < 1 || slippageBps > 1000) { + throw new Error('slippageBps must be 1-1000 (0.01%-10%)'); + } + + const provider = await pickProvider(BSC_RPCS, BSC_CHAIN_ID); + + // Gas tier + const tier: FeeTier = p.feeTier ?? 'normal'; + const fee = await getEvmFeeForTier('BSC', tier); + const capWei = ethers.utils.parseUnits(String(MAX_GAS_PRICE_GWEI), 'gwei'); + const maxFeePerGas = ethers.BigNumber.from(fee.maxFeePerGas); + if (maxFeePerGas.gt(capWei)) { + throw new Error('Gas fee exceeds policy cap'); + } + + // Quote via getAmountsOut + const routerContract = new ethers.Contract(PANCAKE_ROUTER, ROUTER_ABI, provider); + const path = [BSC_TOKEN_MAP[fromUpper], BSC_TOKEN_MAP[toUpper]]; + const amountsOut: ethers.BigNumber[] = await withTimeout( + routerContract.getAmountsOut(p.amount, path), + HTTP_TIMEOUT_MS, + 'PancakeSwap quote timed out', + ); + const expectedOut = amountsOut[amountsOut.length - 1]; + if (expectedOut.lte(0)) { + throw new Error('PancakeSwap quote returned 0 — no liquidity for this pair'); + } + const minOut = expectedOut.mul(10000 - slippageBps).div(10000); + + // Allowance check — approveRequired? (только для token-in) + let approveRequired = false; + if (fromUpper !== 'BNB') { + const tokenContract = new ethers.Contract(BSC_TOKEN_MAP[fromUpper], ERC20_ABI, provider); + try { + const currentAllowance: ethers.BigNumber = await withTimeout( + tokenContract.allowance(p.fromAddress, PANCAKE_ROUTER), + HTTP_TIMEOUT_MS, + 'Allowance check timed out', + ); + approveRequired = currentAllowance.lt(ethers.BigNumber.from(p.amount)); + } catch { + // Allowance check failed → assume approve needed (conservative) + approveRequired = true; + } + } + + // Estimate gas (rough; without simulating actual approve) + let estGas = ethers.BigNumber.from(approveRequired ? 330_000 : 250_000); + try { + const deadline = Math.floor(Date.now() / 1000) + 1200; + let swapData: string; + let value: ethers.BigNumber; + if (fromUpper === 'BNB') { + swapData = routerContract.interface.encodeFunctionData( + 'swapExactETHForTokensSupportingFeeOnTransferTokens', + [minOut, path, p.fromAddress, deadline], + ); + value = ethers.BigNumber.from(p.amount); + } else if (toUpper === 'BNB') { + swapData = routerContract.interface.encodeFunctionData( + 'swapExactTokensForETHSupportingFeeOnTransferTokens', + [p.amount, minOut, path, p.fromAddress, deadline], + ); + value = ethers.BigNumber.from(0); + } else { + swapData = routerContract.interface.encodeFunctionData( + 'swapExactTokensForTokensSupportingFeeOnTransferTokens', + [p.amount, minOut, path, p.fromAddress, deadline], + ); + value = ethers.BigNumber.from(0); + } + // estimateGas работает только если уже approve'd. Если нет — skip estimate, оставляем дефолт. + if (!approveRequired) { + const estimated = await provider.estimateGas({ + from: p.fromAddress, + to: PANCAKE_ROUTER, + data: swapData, + value, + }); + estGas = estimated.mul(120).div(100); + const minGas = ethers.BigNumber.from(150_000); + const maxGas = ethers.BigNumber.from(500_000); + if (estGas.lt(minGas)) estGas = minGas; + if (estGas.gt(maxGas)) estGas = maxGas; + } else { + // Сложить approve (~80k) + swap (~250k) для approximate fee + estGas = ethers.BigNumber.from(330_000); + } + } catch { + // Estimate failed — оставляем default + } + + const networkFeeWei = estGas.mul(maxFeePerGas); + + return { + amountIn: p.amount, + expectedOut: expectedOut.toString(), + minOut: minOut.toString(), + slippageBps, + route: [fromUpper, toUpper], + approveRequired, + estimatedGasUnits: estGas.toString(), + networkFee: { + asset: 'BNB', + amount: networkFeeWei.toString(), + }, + fromDecimals: bscTokenDecimals(fromUpper), + toDecimals: bscTokenDecimals(toUpper), + }; +} + +/** + * BSC chained execute (формерно `swapBsc`). Если `from` не нативный BNB и allowance < amount — * сначала approve(exact), wait 1 confirmation, потом swap. * + * Если `lockedMinOut` задан (2-step flow с quote→execute) — используется он, иначе + * re-quote on-chain (legacy single-shot). + * * Returns: { approveTxid?, swapTxid } */ -export async function swapBsc(p: SwapBscParams): Promise<{ approveTxid?: string; swapTxid: string }> { +export async function executeBsc(p: ExecuteBscParams): Promise<{ approveTxid?: string; swapTxid: string }> { const fromUpper = p.from.toUpperCase(); const toUpper = p.to.toUpperCase(); @@ -126,20 +307,24 @@ export async function swapBsc(p: SwapBscParams): Promise<{ approveTxid?: string; throw new Error('Gas fee invariant violated'); } - // Quote via getAmountsOut → compute amountOutMin server-side (anti-MEV) + // minOut: locked from quote OR re-quote on-chain const routerContract = new ethers.Contract(PANCAKE_ROUTER, ROUTER_ABI, provider); const path = [BSC_TOKEN_MAP[fromUpper], BSC_TOKEN_MAP[toUpper]]; - const amountsOut: ethers.BigNumber[] = await withTimeout( - routerContract.getAmountsOut(p.amount, path), - HTTP_TIMEOUT_MS, - 'PancakeSwap quote timed out', - ); - const expectedOut = amountsOut[amountsOut.length - 1]; - if (expectedOut.lte(0)) { - throw new Error('PancakeSwap quote returned 0 — no liquidity for this pair'); + let amountOutMin: ethers.BigNumber; + if (p.lockedMinOut && /^\d+$/.test(p.lockedMinOut)) { + amountOutMin = ethers.BigNumber.from(p.lockedMinOut); + } else { + const amountsOut: ethers.BigNumber[] = await withTimeout( + routerContract.getAmountsOut(p.amount, path), + HTTP_TIMEOUT_MS, + 'PancakeSwap quote timed out', + ); + const expectedOut = amountsOut[amountsOut.length - 1]; + if (expectedOut.lte(0)) { + throw new Error('PancakeSwap quote returned 0 — no liquidity for this pair'); + } + amountOutMin = expectedOut.mul(10000 - slippageBps).div(10000); } - // amountOutMin = expectedOut × (10000 - slippageBps) / 10000 - const amountOutMin = expectedOut.mul(10000 - slippageBps).div(10000); const deadline = Math.floor(Date.now() / 1000) + 1200; // 20 minutes const feeFields: Partial = { @@ -286,6 +471,24 @@ export interface SwapTrxParams { slippageBps?: number; } +export interface QuoteTrxParams { + fromAddress: string; // base58 tron address + from: string; + to: string; + amount: string; + slippageBps?: number; +} + +export interface ExecuteTrxParams { + mnemonic: string; + expectedFromAddress: string; + from: string; + to: string; + amount: string; + slippageBps?: number; + lockedMinOut?: string; +} + async function fetchJson(url: string, init?: RequestInit): Promise { const controller = new AbortController(); const t = setTimeout(() => controller.abort(), HTTP_TIMEOUT_MS); @@ -587,19 +790,90 @@ async function waitTrxInclusion( } /** - * TRX swap через SunSwap V2 + FeeSwapRouter (0.7% fee). - * Поддерживает только TRX↔USDT (same scope как legacy /tron/swap/build). + * TRX quote — read-only расчёт expected output для SunSwap V2 + FeeSwapRouter (0.7% fee). + * Только TRX↔USDT. Не требует mnemonic. + * + * Network fee: для TRX swap fee оценивается как ~30 TRX (typical SunSwap energy cost при отсутствии resources). + * Это approximation — реальный fee может быть 0 если у юзера достаточно frozen energy/bandwidth. + */ +export async function quoteTrx(p: QuoteTrxParams): Promise { + 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 slippageBpsNum = p.slippageBps !== undefined && Number.isFinite(p.slippageBps) ? p.slippageBps : 50; + const slippageBps = BigInt(slippageBpsNum); + if (slippageBps < 1n || slippageBps > 1000n) { + throw new Error('TRX swap: slippageBps must be between 1 and 1000'); + } + + const headers: Record = { 'Content-Type': 'application/json' }; + if (env.tronApiKey) headers['TRON-PRO-API-KEY'] = env.tronApiKey; + + // Compute fee + swap split (FeeSwapRouter забирает 0.7%). + const feeAmount = (amount * FEE_BPS) / BPS_DENOMINATOR; + const swapAmount = amount - feeAmount; + + 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?`); + } + + // Allowance check (только USDT → TRX) + let approveRequired = false; + if (!isTrxToUsdt) { + try { + const allowance = await checkAllowance(p.fromAddress, USDT_CONTRACT, FEE_SWAP_ROUTER_TRX, headers); + approveRequired = allowance < amount; + } catch { + approveRequired = true; + } + } + + // Network fee approximation (TRON energy → ~30 TRX for SunSwap, ~15 if pre-approved). + // TRX has 6 decimals → 30_000_000 sun = 30 TRX. + const networkFeeSun = approveRequired ? 45_000_000n : 30_000_000n; + + return { + amountIn: p.amount, + expectedOut: quote.toString(), + minOut: amountOutMin.toString(), + slippageBps: slippageBpsNum, + route: [fromU, toU], + approveRequired, + estimatedGasUnits: undefined, + networkFee: { + asset: 'TRX', + amount: networkFeeSun.toString(), + }, + fromDecimals: fromInfo.decimals, + toDecimals: toInfo.decimals, + }; +} + +/** + * TRX execute (формерно `swapTrx`) — broadcast swap через SunSwap V2 + FeeSwapRouter (0.7% fee). + * Поддерживает только TRX↔USDT. * * 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. + * Slippage: если `lockedMinOut` задан → используется. Иначе re-quote on-chain. */ -export async function swapTrx( - p: SwapTrxParams, +export async function executeTrx( + p: ExecuteTrxParams, ): Promise<{ approveTxid?: string; swapTxid: string }> { const fromU = p.from.toUpperCase(); const toU = p.to.toUpperCase(); @@ -633,15 +907,21 @@ export async function swapTrx( 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?`); + + // amountOutMin: lockedMinOut from quote OR re-quote on-chain + let amountOutMin: bigint; + if (p.lockedMinOut && /^\d+$/.test(p.lockedMinOut)) { + amountOutMin = BigInt(p.lockedMinOut); + } else { + const quote = await getAmountsOut(swapAmount, path, headers); + 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 минут @@ -781,10 +1061,114 @@ export interface SwapSolParams { slippageBps?: number; } +export interface QuoteSolParams { + inputMint: string; + outputMint: string; + amount: string; + slippageBps?: number; +} + +export interface QuoteSolResult extends SwapQuoteRaw { + /** Jupiter /quote response object — нужен для execute reuse. */ + jupiterQuoteResponse: any; + priceImpactPct?: string; +} + +export interface ExecuteSolParams { + mnemonic: string; + expectedFromAddress: string; + inputMint: string; + outputMint: string; + amount: string; + slippageBps?: number; + /** Кешированный Jupiter /quote response (для re-use на /swap step). */ + jupiterQuoteResponse?: any; +} + +/** Возвращает decimals для known SOL mint (по token-registry). Wrapped SOL = 9. */ +function solMintDecimals(mint: string): number { + if (mint === SOL_NATIVE_WRAPPED_MINT) return 9; + const t = getSolTokens().find((x) => x.mint === mint); + return t?.decimals ?? 6; +} + +function validateSolParams(inputMint: string, outputMint: string, slippageBpsRaw?: number): number { + if (!isAllowedSolMint(inputMint)) { + throw new Error(`SOL swap inputMint not in whitelist: ${inputMint}`); + } + if (!isAllowedSolMint(outputMint)) { + throw new Error(`SOL swap outputMint not in whitelist: ${outputMint}`); + } + if (inputMint === outputMint) { + throw new Error('SOL swap: inputMint === outputMint'); + } + const slippageBps = slippageBpsRaw ?? 50; + if (slippageBps < 1 || slippageBps > 1000) { + throw new Error('slippageBps must be 1-1000'); + } + return slippageBps; +} + /** - * SOL Jupiter chained swap. Получаем quote от Jupiter, build serialized tx, sign keypair'ом, broadcast. + * SOL quote — fetches Jupiter /quote (read-only, no broadcast). + * Returns expectedOut, minOut, route info, плюс full Jupiter quote response + * (нужен для execute step, который зовёт Jupiter /swap с тем же quoteResponse). */ -export async function swapSol(p: SwapSolParams): Promise<{ signature: string }> { +export async function quoteSol(p: QuoteSolParams): Promise { + const slippageBps = validateSolParams(p.inputMint, p.outputMint, p.slippageBps); + if (!/^\d+$/.test(p.amount) || BigInt(p.amount) <= 0n) { + throw new Error('SOL swap: amount must be positive integer string'); + } + + const quoteUrl = `${JUPITER_API}/quote?inputMint=${encodeURIComponent(p.inputMint)}&outputMint=${encodeURIComponent(p.outputMint)}&amount=${encodeURIComponent(p.amount)}&slippageBps=${slippageBps}`; + const headers: Record = { Accept: 'application/json' }; + if (env.jupiterApiKey) headers['x-api-key'] = env.jupiterApiKey; + const quoteRes = await fetchJson(quoteUrl, { headers }); + + // Jupiter quote response shape: + // inAmount, outAmount, otherAmountThreshold (= minOut при ExactIn), + // priceImpactPct, routePlan: [{ swapInfo: { label, ... } }, ...] + const expectedOut = String(quoteRes.outAmount ?? '0'); + const minOut = String(quoteRes.otherAmountThreshold ?? expectedOut); + + // Build human-readable route (DEX names или mint→mint). + let route: string[] = []; + const plan = Array.isArray(quoteRes.routePlan) ? quoteRes.routePlan : []; + if (plan.length > 0) { + route = plan.map((hop: any) => hop?.swapInfo?.label ?? 'unknown'); + } else { + route = ['Jupiter']; + } + + // Network fee на SOL — Jupiter префронтит "auto" prioritization fee. Typical: ~5000-20000 lamports + // base + до 100000 priority. Точное значение видим только в /swap response (computeBudgetInstructions). + // Approximation: 25000 lamports = 0.000025 SOL. Это conservative upper bound. + const networkFeeLamports = '25000'; + + return { + amountIn: p.amount, + expectedOut, + minOut, + slippageBps, + route, + approveRequired: false, // SOL не требует ERC20-style approve + estimatedGasUnits: undefined, + networkFee: { + asset: 'SOL', + amount: networkFeeLamports, + }, + fromDecimals: solMintDecimals(p.inputMint), + toDecimals: solMintDecimals(p.outputMint), + jupiterQuoteResponse: quoteRes, + priceImpactPct: quoteRes.priceImpactPct ? String(quoteRes.priceImpactPct) : undefined, + }; +} + +/** + * SOL execute (формерно `swapSol`) — Jupiter chained swap. + * Принимает либо `jupiterQuoteResponse` (locked from quote step) либо re-fetch'ит. + */ +export async function executeSol(p: ExecuteSolParams): Promise<{ signature: string }> { const seed = await bip39.mnemonicToSeed(p.mnemonic); const { key } = derivePath(DERIVATION_PATHS.SOL, seed.toString('hex')); if (!key || key.length !== 32) { @@ -795,29 +1179,17 @@ export async function swapSol(p: SwapSolParams): Promise<{ signature: string }> throw new Error(`SOL address mismatch: derived ${keypair.publicKey.toBase58()} ≠ DB ${p.expectedFromAddress}`); } - const slippageBps = p.slippageBps ?? 50; - if (slippageBps < 1 || slippageBps > 1000) { - throw new Error('slippageBps must be 1-1000'); - } + const slippageBps = validateSolParams(p.inputMint, p.outputMint, p.slippageBps); - // Mint whitelist — соответствует sol-swap-proxy.ALLOWED_MINTS + token-registry.SOL_TOKENS. - // Без этого custodial endpoint позволил бы swap'ать произвольные SPL mints (rugpull tokens, - // honeypots) — клиент мог бы дренить wallet через malicious mint quote. - if (!isAllowedSolMint(p.inputMint)) { - throw new Error(`SOL swap inputMint not in whitelist: ${p.inputMint}`); - } - if (!isAllowedSolMint(p.outputMint)) { - throw new Error(`SOL swap outputMint not in whitelist: ${p.outputMint}`); - } - if (p.inputMint === p.outputMint) { - throw new Error('SOL swap: inputMint === outputMint'); - } - - // 1. Jupiter quote - const quoteUrl = `${JUPITER_API}/quote?inputMint=${encodeURIComponent(p.inputMint)}&outputMint=${encodeURIComponent(p.outputMint)}&amount=${encodeURIComponent(p.amount)}&slippageBps=${slippageBps}`; const headers: Record = { Accept: 'application/json' }; if (env.jupiterApiKey) headers['x-api-key'] = env.jupiterApiKey; - const quoteRes = await fetchJson(quoteUrl, { headers }); + + // Re-use cached quote OR fetch fresh. + let quoteRes = p.jupiterQuoteResponse; + if (!quoteRes) { + const quoteUrl = `${JUPITER_API}/quote?inputMint=${encodeURIComponent(p.inputMint)}&outputMint=${encodeURIComponent(p.outputMint)}&amount=${encodeURIComponent(p.amount)}&slippageBps=${slippageBps}`; + quoteRes = await fetchJson(quoteUrl, { headers }); + } // 2. Jupiter swap (build serialized tx) const swapBody: Record = { diff --git a/apps/api/swagger.json b/apps/api/swagger.json index fd7eb4a..ffc7790 100644 --- a/apps/api/swagger.json +++ b/apps/api/swagger.json @@ -6,67 +6,151 @@ "description": "Multi-chain custodial wallet API (ETH/BSC/BTC/TRX/SOL). Сервер генерит mnemonic, шифрует AES-256-GCM (master-key из HashiCorp Vault), хранит её и сам подписывает транзакции. Auth via JWT (cookie/Bearer), issued by external auth-service (BITOK)." }, "servers": [ - { "url": "/api", "description": "API root" } + { + "url": "/api", + "description": "API root" + } ], "tags": [ - { "name": "System", "description": "Health & service info" }, - { "name": "Wallets", "description": "Custodial wallet lifecycle" }, - { "name": "Wallet Ops", "description": "Per-chain balance / transactions / send" }, - { "name": "BTC", "description": "Bitcoin RPC proxy (Blockstream)" }, - { "name": "TRON", "description": "TRON RPC proxy (TronGrid)" }, - { "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": "Prices", "description": "USD-цены (CoinGecko + KeyDB cache 5 мин)" } + { + "name": "System", + "description": "Health & service info" + }, + { + "name": "Wallets", + "description": "Custodial wallet lifecycle" + }, + { + "name": "Wallet Ops", + "description": "Per-chain balance / transactions / send" + }, + { + "name": "BTC", + "description": "Bitcoin RPC proxy (Blockstream)" + }, + { + "name": "TRON", + "description": "TRON RPC proxy (TronGrid)" + }, + { + "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": "Prices", + "description": "USD-цены (CoinGecko + KeyDB cache 5 мин)" + } ], "components": { "securitySchemes": { - "bearerAuth": { "type": "http", "scheme": "bearer", "bearerFormat": "JWT" }, - "cookieAuth": { "type": "apiKey", "in": "cookie", "name": "access_token" } + "bearerAuth": { + "type": "http", + "scheme": "bearer", + "bearerFormat": "JWT" + }, + "cookieAuth": { + "type": "apiKey", + "in": "cookie", + "name": "access_token" + } }, "schemas": { "Error": { "type": "object", "properties": { - "success": { "type": "boolean", "example": false }, - "error": { "type": "string" } + "success": { + "type": "boolean", + "example": false + }, + "error": { + "type": "string" + } } }, "HealthResponse": { "type": "object", "properties": { - "success": { "type": "boolean", "example": true }, - "data": { "type": "object", "properties": { "status": { "type": "string", "example": "ok" } } } + "success": { + "type": "boolean", + "example": true + }, + "data": { + "type": "object", + "properties": { + "status": { + "type": "string", + "example": "ok" + } + } + } } }, "Chain": { "type": "string", - "enum": ["ETH", "BTC", "SOL", "TRX", "BSC"] + "enum": [ + "ETH", + "BTC", + "SOL", + "TRX", + "BSC" + ] }, "Wallet": { "type": "object", "properties": { - "chain": { "$ref": "#/components/schemas/Chain" }, - "address": { "type": "string" }, - "derivationPath": { "type": "string", "description": "BIP32 path" } + "chain": { + "$ref": "#/components/schemas/Chain" + }, + "address": { + "type": "string" + }, + "derivationPath": { + "type": "string", + "description": "BIP32 path" + } } }, "WalletsResponse": { "type": "object", "properties": { - "success": { "type": "boolean", "example": true }, - "data": { "type": "array", "items": { "$ref": "#/components/schemas/Wallet" } } + "success": { + "type": "boolean", + "example": true + }, + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Wallet" + } + } } }, "MnemonicResponse": { "type": "object", "properties": { - "success": { "type": "boolean", "example": true }, + "success": { + "type": "boolean", + "example": true + }, "data": { "type": "object", "properties": { - "mnemonic": { "type": "string", "description": "BIP39 mnemonic (12 words)" } + "mnemonic": { + "type": "string", + "description": "BIP39 mnemonic (12 words)" + } } } } @@ -74,12 +158,20 @@ "TxBroadcastResponse": { "type": "object", "properties": { - "success": { "type": "boolean", "example": true }, + "success": { + "type": "boolean", + "example": true + }, "data": { "type": "object", "properties": { - "txid": { "type": "string", "description": "Идентификатор отправленной транзакции" }, - "chain": { "$ref": "#/components/schemas/Chain" } + "txid": { + "type": "string", + "description": "Идентификатор отправленной транзакции" + }, + "chain": { + "$ref": "#/components/schemas/Chain" + } } } } @@ -87,11 +179,29 @@ "FormattedAmount": { "type": "object", "description": "Сумма с метаданными формата + USD-цена. Поля `usdPrice`/`usdValue` всегда присутствуют, но могут быть `null` если symbol не в registry или upstream price oracle (CoinGecko) недоступен.", - "required": ["raw", "formatted", "decimals", "usdPrice", "usdValue"], + "required": [ + "raw", + "formatted", + "decimals", + "usdPrice", + "usdValue" + ], "properties": { - "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 }, + "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, @@ -109,20 +219,33 @@ "PricesResponse": { "type": "object", "properties": { - "success": { "type": "boolean", "example": true }, + "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 } + "usd": { + "type": "number", + "nullable": true, + "example": 67432.12 + } } }, "example": { - "BTC": { "usd": 67432.12 }, - "ETH": { "usd": 3210.45 }, - "USDT": { "usd": 1.0 } + "BTC": { + "usd": 67432.12 + }, + "ETH": { + "usd": 3210.45 + }, + "USDT": { + "usd": 1 + } } } } @@ -130,17 +253,28 @@ "BalanceResponse": { "type": "object", "properties": { - "success": { "type": "boolean", "example": true }, + "success": { + "type": "boolean", + "example": true + }, "data": { "type": "object", "properties": { - "chain": { "$ref": "#/components/schemas/Chain" }, - "address": { "type": "string" }, - "native": { "$ref": "#/components/schemas/FormattedAmount" }, + "chain": { + "$ref": "#/components/schemas/Chain" + }, + "address": { + "type": "string" + }, + "native": { + "$ref": "#/components/schemas/FormattedAmount" + }, "tokens": { "type": "object", "description": "Map symbol → FormattedAmount. Содержит все известные токены chain'а (ETH: USDT/USDC/DAI/WBTC/LINK/UNI, BSC: USDT/USDC/DOGE/WBNB/BUSD, TRX: USDT/USDC, SOL: 14 токенов)", - "additionalProperties": { "$ref": "#/components/schemas/FormattedAmount" } + "additionalProperties": { + "$ref": "#/components/schemas/FormattedAmount" + } } } } @@ -150,38 +284,88 @@ "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" }, + "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" } + "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)" } + "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 }, + "success": { + "type": "boolean", + "example": true + }, "data": { "type": "object", - "required": ["totalUsd", "hasErrors", "perChain"], + "required": [ + "totalUsd", + "hasErrors", + "perChain" + ], "properties": { - "totalUsd": { "type": "number", "description": "Grand sum USD по всем сетям (rounded к 8 знакам). 0 если все сети упали и нет cache." }, - "hasErrors": { "type": "boolean", "description": "true если хотя бы одна сеть в stale/error состоянии" }, + "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 } + "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 + } } } } @@ -191,32 +375,81 @@ "Transaction": { "type": "object", "properties": { - "txid": { "type": "string" }, - "timestamp": { "type": "integer", "nullable": true, "description": "Unix seconds" }, - "direction": { "type": "string", "enum": ["in", "out", "self"] }, - "amount": { "type": "string", "nullable": true }, - "token": { "type": "string", "nullable": true }, - "from": { "type": "string", "nullable": true }, - "to": { "type": "string", "nullable": true } + "txid": { + "type": "string" + }, + "timestamp": { + "type": "integer", + "nullable": true, + "description": "Unix seconds" + }, + "direction": { + "type": "string", + "enum": [ + "in", + "out", + "self" + ] + }, + "amount": { + "type": "string", + "nullable": true + }, + "token": { + "type": "string", + "nullable": true + }, + "from": { + "type": "string", + "nullable": true + }, + "to": { + "type": "string", + "nullable": true + } } }, "TransactionsResponse": { "type": "object", "properties": { - "success": { "type": "boolean" }, - "data": { "type": "array", "items": { "$ref": "#/components/schemas/Transaction" } } + "success": { + "type": "boolean" + }, + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Transaction" + } + } } }, "SendRequest": { "type": "object", - "required": ["to", "amount"], + "required": [ + "to", + "amount" + ], "properties": { - "to": { "type": "string", "description": "Recipient address" }, - "amount": { "type": "string", "description": "Amount в smallest units (wei для EVM, lamports для SOL, sat для BTC, sun для TRX)" }, - "token": { "type": "string", "nullable": true, "description": "USDT для TRC20/ERC20/BEP20. Без token = native." }, + "to": { + "type": "string", + "description": "Recipient address" + }, + "amount": { + "type": "string", + "description": "Amount в smallest units (wei для EVM, lamports для SOL, sat для BTC, sun для TRX)" + }, + "token": { + "type": "string", + "nullable": true, + "description": "USDT для TRC20/ERC20/BEP20. Без token = native." + }, "feeTier": { "type": "string", - "enum": ["slow", "normal", "fast"], + "enum": [ + "slow", + "normal", + "fast" + ], "nullable": true, "description": "Default 'normal'. ETH/BSC: eth_feeHistory p25/p50/p75 priority. BTC: blockstream targets 144/6/1 блок. TRX/SOL: игнорится." } @@ -225,159 +458,538 @@ "FeeQuote": { "type": "object", "properties": { - "maxFeePerGas": { "type": "string", "description": "wei (decimal string)" }, - "maxPriorityFeePerGas": { "type": "string", "description": "wei (decimal string)" }, - "gweiTotal": { "type": "number" }, - "gweiPriority": { "type": "number" } + "maxFeePerGas": { + "type": "string", + "description": "wei (decimal string)" + }, + "maxPriorityFeePerGas": { + "type": "string", + "description": "wei (decimal string)" + }, + "gweiTotal": { + "type": "number" + }, + "gweiPriority": { + "type": "number" + } } }, "FeeTiers": { "type": "object", "properties": { - "chain": { "type": "string", "enum": ["ETH", "BSC"] }, - "baseFeeGwei": { "type": "number", "description": "Из feeHistory.baseFeePerGas (на BSC ~0)" }, - "slow": { "$ref": "#/components/schemas/FeeQuote" }, - "normal": { "$ref": "#/components/schemas/FeeQuote" }, - "fast": { "$ref": "#/components/schemas/FeeQuote" } + "chain": { + "type": "string", + "enum": [ + "ETH", + "BSC" + ] + }, + "baseFeeGwei": { + "type": "number", + "description": "Из feeHistory.baseFeePerGas (на BSC ~0)" + }, + "slow": { + "$ref": "#/components/schemas/FeeQuote" + }, + "normal": { + "$ref": "#/components/schemas/FeeQuote" + }, + "fast": { + "$ref": "#/components/schemas/FeeQuote" + } } }, "SignRawEvmTxRequest": { "type": "object", - "required": ["to", "data", "value", "chainId", "gas", "maxFeePerGas", "maxPriorityFeePerGas"], + "required": [ + "to", + "data", + "value", + "chainId", + "gas", + "maxFeePerGas", + "maxPriorityFeePerGas" + ], "properties": { - "to": { "type": "string", "description": "0x-prefixed 40-hex (контракт или EOA)" }, - "data": { "type": "string", "description": "Calldata 0x-hex (может быть пустым 0x для native send)" }, - "value": { "type": "string", "description": "wei (decimal string)" }, - "chainId": { "type": "integer", "description": "1 (ETH) или 56 (BSC) — должен совпадать с path :chain" }, - "gas": { "type": "string", "description": "gasLimit в decimal" }, - "maxFeePerGas": { "type": "string", "description": "wei" }, - "maxPriorityFeePerGas": { "type": "string", "description": "wei" }, + "to": { + "type": "string", + "description": "0x-prefixed 40-hex (контракт или EOA)" + }, + "data": { + "type": "string", + "description": "Calldata 0x-hex (может быть пустым 0x для native send)" + }, + "value": { + "type": "string", + "description": "wei (decimal string)" + }, + "chainId": { + "type": "integer", + "description": "1 (ETH) или 56 (BSC) — должен совпадать с path :chain" + }, + "gas": { + "type": "string", + "description": "gasLimit в decimal" + }, + "maxFeePerGas": { + "type": "string", + "description": "wei" + }, + "maxPriorityFeePerGas": { + "type": "string", + "description": "wei" + }, "feeTier": { "type": "string", - "enum": ["slow", "normal", "fast"], + "enum": [ + "slow", + "normal", + "fast" + ], "nullable": true, "description": "Если задан → server переопределит maxFeePerGas/maxPriorityFeePerGas актуальным из eth_feeHistory (полезно если quote от Relay устарел)." } } + }, + "SwapQuoteResponse": { + "type": "object", + "required": [ + "quoteId", + "expiresIn", + "expiresAt", + "chain", + "amountIn", + "amountInFormatted", + "expectedOut", + "expectedOutFormatted", + "minOut", + "minOutFormatted", + "slippageBps", + "fees", + "route", + "approveRequired" + ], + "properties": { + "quoteId": { + "type": "string", + "example": "q_01KRKD8GA4XZJ5W4E7VFT2N9M3", + "description": "Opaque ULID. Pass to POST /:chain/swap для execute." + }, + "expiresIn": { + "type": "integer", + "example": 30, + "description": "Seconds until cache eviction" + }, + "expiresAt": { + "type": "integer", + "format": "int64", + "example": 1715600030000, + "description": "Unix ms when quote expires" + }, + "chain": { + "type": "string", + "enum": [ + "BSC", + "TRX", + "SOL" + ] + }, + "amountIn": { + "type": "string", + "example": "100000000000000000", + "description": "Smallest units" + }, + "amountInFormatted": { + "type": "string", + "example": "0.1" + }, + "amountInUsd": { + "type": "number", + "nullable": true, + "example": 0.1 + }, + "expectedOut": { + "type": "string", + "example": "164821000000000", + "description": "Mid-market quote (smallest units)" + }, + "expectedOutFormatted": { + "type": "string", + "example": "0.000164821" + }, + "expectedOutUsd": { + "type": "number", + "nullable": true, + "example": 0.0991 + }, + "minOut": { + "type": "string", + "example": "163996895000000", + "description": "expectedOut × (10000-slippageBps) / 10000" + }, + "minOutFormatted": { + "type": "string", + "example": "0.000163996" + }, + "slippageBps": { + "type": "integer", + "example": 50, + "description": "Slippage в basis points (50 = 0.5%)" + }, + "priceImpactPct": { + "type": "string", + "nullable": true, + "example": "0.06", + "description": "SOL only (Jupiter exposes это поле)" + }, + "fees": { + "type": "object", + "properties": { + "network": { + "type": "object", + "properties": { + "asset": { + "type": "string", + "example": "BNB" + }, + "amount": { + "type": "string", + "example": "65000000000000" + }, + "amountFormatted": { + "type": "string", + "example": "0.000065" + }, + "amountUsd": { + "type": "number", + "nullable": true, + "example": 0.04 + } + } + }, + "total": { + "type": "object", + "properties": { + "amountUsd": { + "type": "number", + "nullable": true, + "example": 0.04 + } + } + } + } + }, + "route": { + "type": "array", + "items": { + "type": "string" + }, + "example": [ + "USDT", + "BNB" + ], + "description": "Symbol path (для BSC/TRX) или DEX labels (для SOL Jupiter)." + }, + "approveRequired": { + "type": "boolean", + "example": true, + "description": "BSC: token-to-anything требует approve(amount). TRX: USDT→TRX requires approve(infinite)." + }, + "estimatedGasUnits": { + "type": "string", + "nullable": true, + "example": "300000", + "description": "EVM gas units (BSC). Null для TRX/SOL." + } + } } } }, "security": [ - { "cookieAuth": [] }, - { "bearerAuth": [] } + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } ], "paths": { "/health": { "get": { "summary": "Liveness check", - "tags": ["System"], + "tags": [ + "System" + ], "security": [], - "responses": { "200": { "description": "OK", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/HealthResponse" } } } } } - } - }, - - "/wallets": { - "get": { - "summary": "Get all wallets of authenticated user", - "tags": ["Wallets"], "responses": { - "200": { "description": "List of wallets", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/WalletsResponse" } } } }, - "401": { "description": "Not authenticated", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } } + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HealthResponse" + } + } + } + } + } + } + }, + "/wallets": { + "get": { + "summary": "Get all wallets of authenticated user", + "tags": [ + "Wallets" + ], + "responses": { + "200": { + "description": "List of wallets", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/WalletsResponse" + } + } + } + }, + "401": { + "description": "Not authenticated", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } } } }, - "/wallets/create": { "post": { "summary": "Создать custodial-кошелёк (server-side mnemonic)", "description": "**Тело запроса не требуется.** Сервер генерит BIP39 mnemonic (12 слов), деривит адреса для 5 chains (BIP44: ETH m/44'/60'/0'/0/0, BTC m/84'/0'/0'/0/0, TRX m/44'/195'/0'/0/0, SOL m/44'/501'/0'/0', BSC = ETH path), шифрует mnemonic AES-256-GCM (master-key из HashiCorp Vault) и атомарно сохраняет. **Возвращает ТОЛЬКО адреса** — mnemonic клиенту не отдаётся. Чтобы потом увидеть seed — отдельный endpoint POST /wallets/mnemonic/reveal. Идемпотентность: 409 если у юзера уже есть кошелёк.", - "tags": ["Wallets"], + "tags": [ + "Wallets" + ], "responses": { - "201": { "description": "Wallet created (returns addresses only)", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/WalletsResponse" } } } }, - "401": { "description": "Not authenticated" }, - "409": { "description": "Wallet already exists", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, - "503": { "description": "Crypto service not ready" } + "201": { + "description": "Wallet created (returns addresses only)", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/WalletsResponse" + } + } + } + }, + "401": { + "description": "Not authenticated" + }, + "409": { + "description": "Wallet already exists", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "503": { + "description": "Crypto service not ready" + } } } }, - "/wallets/mnemonic/reveal": { "post": { "summary": "Раскрыть mnemonic (settings-screen)", "description": "Расшифровывает и возвращает 12-словную BIP39 мнемонику юзера. POST + CSRF + body-confirmation. Rate-limit 5/час per-user. Каждый запрос пишется в audit-log.", - "tags": ["Wallets"], + "tags": [ + "Wallets" + ], "requestBody": { "required": true, "content": { "application/json": { "schema": { "type": "object", - "required": ["confirm"], + "required": [ + "confirm" + ], "properties": { - "confirm": { "type": "string", "enum": ["I_UNDERSTAND_SEED_IS_SECRET"] } + "confirm": { + "type": "string", + "enum": [ + "I_UNDERSTAND_SEED_IS_SECRET" + ] + } } } } } }, "responses": { - "200": { "description": "Mnemonic revealed", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/MnemonicResponse" } } } }, - "400": { "description": "Missing/invalid confirm token" }, - "401": { "description": "Not authenticated" }, - "404": { "description": "Wallet not created yet" }, - "429": { "description": "Rate limit (5/hour) exceeded" }, - "503": { "description": "Crypto service not ready" } + "200": { + "description": "Mnemonic revealed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MnemonicResponse" + } + } + } + }, + "400": { + "description": "Missing/invalid confirm token" + }, + "401": { + "description": "Not authenticated" + }, + "404": { + "description": "Wallet not created yet" + }, + "429": { + "description": "Rate limit (5/hour) exceeded" + }, + "503": { + "description": "Crypto service not ready" + } } } }, - "/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"], + "tags": [ + "Wallet Ops" + ], "responses": { "200": { "description": "Aggregate portfolio", "content": { "application/json": { - "schema": { "$ref": "#/components/schemas/PortfolioResponse" }, + "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 } + "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.1, + "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" } + "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-ценами)", "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" } }], + "tags": [ + "Wallet Ops" + ], + "parameters": [ + { + "name": "chain", + "in": "path", + "required": true, + "schema": { + "$ref": "#/components/schemas/Chain" + } + } + ], "responses": { "200": { "description": "Balance + USD prices", "content": { "application/json": { - "schema": { "$ref": "#/components/schemas/BalanceResponse" }, + "schema": { + "$ref": "#/components/schemas/BalanceResponse" + }, "example": { "success": true, "data": { @@ -391,56 +1003,159 @@ "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 } + "USDT": { + "raw": "1000000", + "formatted": "1", + "decimals": 6, + "usdPrice": 1, + "usdValue": 1 + }, + "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" } + "401": { + "description": "Not authenticated" + }, + "404": { + "description": "Wallet for this chain not found" + }, + "502": { + "description": "Upstream RPC error" + } } } }, - "/wallets/{chain}/transactions": { "get": { "summary": "Transaction history for user wallet in chain", - "tags": ["Wallet Ops"], + "tags": [ + "Wallet Ops" + ], "parameters": [ - { "name": "chain", "in": "path", "required": true, "schema": { "$ref": "#/components/schemas/Chain" } }, - { "name": "limit", "in": "query", "schema": { "type": "integer", "default": 20, "minimum": 1, "maximum": 100 } } + { + "name": "chain", + "in": "path", + "required": true, + "schema": { + "$ref": "#/components/schemas/Chain" + } + }, + { + "name": "limit", + "in": "query", + "schema": { + "type": "integer", + "default": 20, + "minimum": 1, + "maximum": 100 + } + } ], "responses": { - "200": { "description": "List of transactions", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/TransactionsResponse" } } } }, - "404": { "description": "Wallet for this chain not found" } + "200": { + "description": "List of transactions", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TransactionsResponse" + } + } + } + }, + "404": { + "description": "Wallet for this chain not found" + } } } }, - "/wallets/{chain}/send": { "post": { "summary": "Custodial send: server signs + broadcasts", "description": "Юзер на клиенте жмёт 'подтвердить' → клиент шлёт {to, amount, token?, feeTier?}. Сервер расшифровывает мнемонику, деривит chain privkey, подписывает, broadcast'ит. Возвращает txid. Защита: TRX MITM check, EVM gas cap 500 gwei, SOL confirmTransaction, BTC timeout + safety multiplier. На ETH/BSC gas теперь берётся из eth_feeHistory (slow/normal/fast).", - "tags": ["Wallet Ops"], - "parameters": [{ "name": "chain", "in": "path", "required": true, "schema": { "$ref": "#/components/schemas/Chain" } }], + "tags": [ + "Wallet Ops" + ], + "parameters": [ + { + "name": "chain", + "in": "path", + "required": true, + "schema": { + "$ref": "#/components/schemas/Chain" + } + } + ], "requestBody": { "required": true, - "content": { "application/json": { "schema": { "$ref": "#/components/schemas/SendRequest" } } } + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SendRequest" + } + } + } }, "responses": { - "200": { "description": "Broadcast successful", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/TxBroadcastResponse" } } } }, - "400": { "description": "Invalid input (incl. invalid feeTier)" }, - "404": { "description": "Wallet/mnemonic not found" }, - "502": { "description": "Broadcast failed (insufficient balance / RPC error / unsupported)" }, - "503": { "description": "Crypto service not ready" } + "200": { + "description": "Broadcast successful", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TxBroadcastResponse" + } + } + } + }, + "400": { + "description": "Invalid input (incl. invalid feeTier)" + }, + "404": { + "description": "Wallet/mnemonic not found" + }, + "502": { + "description": "Broadcast failed (insufficient balance / RPC error / unsupported)" + }, + "503": { + "description": "Crypto service not ready" + } } } }, @@ -448,15 +1163,48 @@ "get": { "summary": "EVM gas oracle (slow/normal/fast)", "description": "Парсит fees через `eth_feeHistory` (последние 5 блоков, percentile p25/p50/p75 priority tips). Возвращает 3 тира с maxFeePerGas/maxPriorityFeePerGas в wei + gwei для display. Floor: ETH=0.5 gwei, BSC=0.05 gwei (защита от dust). Cap: 500 gwei. Только ETH и BSC.", - "tags": ["Wallet Ops"], - "parameters": [{ "name": "chain", "in": "path", "required": true, "schema": { "type": "string", "enum": ["ETH", "BSC"] } }], + "tags": [ + "Wallet Ops" + ], + "parameters": [ + { + "name": "chain", + "in": "path", + "required": true, + "schema": { + "type": "string", + "enum": [ + "ETH", + "BSC" + ] + } + } + ], "responses": { "200": { "description": "Fee tiers", - "content": { "application/json": { "schema": { "type": "object", "properties": { "success": { "type": "boolean" }, "data": { "$ref": "#/components/schemas/FeeTiers" } } } } } + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean" + }, + "data": { + "$ref": "#/components/schemas/FeeTiers" + } + } + } + } + } }, - "400": { "description": "Non-EVM chain" }, - "502": { "description": "Upstream RPC error" } + "400": { + "description": "Non-EVM chain" + }, + "502": { + "description": "Upstream RPC error" + } } } }, @@ -464,27 +1212,89 @@ "post": { "summary": "Custodial sign + broadcast arbitrary EVM tx (Relay bridge)", "description": "Подписывает unsigned EVM tx из Relay /execute response. Policy: `to` ДОЛЖЕН быть в Relay router allowlist; selector blacklist (approve/permit/setApprovalForAll). Для DEX swap'ов используй `/wallets/{chain}/swap` — там chained custodial без этих ограничений.", - "tags": ["Wallet Ops"], - "parameters": [{ "name": "chain", "in": "path", "required": true, "schema": { "type": "string", "enum": ["ETH", "BSC"] } }], + "tags": [ + "Wallet Ops" + ], + "parameters": [ + { + "name": "chain", + "in": "path", + "required": true, + "schema": { + "type": "string", + "enum": [ + "ETH", + "BSC" + ] + } + } + ], "requestBody": { "required": true, - "content": { "application/json": { "schema": { "$ref": "#/components/schemas/SignRawEvmTxRequest" } } } + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SignRawEvmTxRequest" + } + } + } }, "responses": { - "200": { "description": "Broadcast successful", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/TxBroadcastResponse" } } } }, - "400": { "description": "Policy violation: to not in allowlist OR forbidden selector OR cap exceeded" }, - "404": { "description": "Wallet/mnemonic not found" }, - "502": { "description": "Broadcast failed" }, - "503": { "description": "Crypto service not ready" } + "200": { + "description": "Broadcast successful", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TxBroadcastResponse" + } + } + } + }, + "400": { + "description": "Policy violation: to not in allowlist OR forbidden selector OR cap exceeded" + }, + "404": { + "description": "Wallet/mnemonic not found" + }, + "502": { + "description": "Broadcast failed" + }, + "503": { + "description": "Crypto service not ready" + } } } }, "/wallets/{chain}/swap": { "post": { - "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"] } }], + "summary": "Custodial swap execute (2-step: после /swap/quote)", + "description": "Выполняет swap с locked-in параметрами из quote (anti-MEV).\n\n**Required flow:**\n1. `POST /api/wallets/{chain}/swap/quote` → возвращает `quoteId` + preview (expectedOut, minOut, fees, route).\n2. Юзер видит preview, жмёт \"Подтвердить\".\n3. `POST /api/wallets/{chain}/swap` с body `{quoteId}` → execute с locked params.\n\n**Quote TTL:** 30 секунд. Если истёк → 410 Gone, юзер refresh'ит quote.\n\n**Anti-replay:** quote удаляется после успешного execute.\n\n**Legacy mode (deprecated):** Body со старой схемой {from/to/amount/...} или {inputMint/outputMint/amount/...} всё ещё работает (re-quote on-chain, без anti-MEV gate). НЕ рекомендуется для UI.", + "tags": [ + "Wallet Ops" + ], + "parameters": [ + { + "name": "chain", + "in": "path", + "required": true, + "schema": { + "type": "string", + "enum": [ + "BSC", + "TRX", + "SOL" + ] + } + }, + { + "name": "Idempotency-Key", + "in": "header", + "required": false, + "schema": { + "type": "string" + } + } + ], "requestBody": { "required": true, "content": { @@ -493,25 +1303,76 @@ "oneOf": [ { "type": "object", - "title": "BSC/TRX swap (symbols)", - "required": ["from", "to", "amount"], + "title": "2-step execute (recommended)", + "required": [ + "quoteId" + ], "properties": { - "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 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 игнорится." } + "quoteId": { + "type": "string", + "description": "ULID quote id, полученный от POST /:chain/swap/quote.", + "example": "q_01KRKD8GA4XZJ5W4E7VFT2N9M3" + } } }, { "type": "object", - "title": "SOL swap (mints)", - "required": ["inputMint", "outputMint", "amount"], + "title": "BSC/TRX legacy single-shot", + "required": [ + "from", + "to", + "amount" + ], "properties": { - "inputMint": { "type": "string", "description": "SPL mint address (base58)" }, - "outputMint": { "type": "string" }, - "amount": { "type": "string", "description": "Smallest units (lamports = 9-dec для SOL native)" }, - "slippageBps": { "type": "integer", "minimum": 1, "maximum": 1000 } + "from": { + "type": "string" + }, + "to": { + "type": "string" + }, + "amount": { + "type": "string" + }, + "slippageBps": { + "type": "integer", + "minimum": 1, + "maximum": 1000, + "default": 50 + }, + "feeTier": { + "type": "string", + "enum": [ + "slow", + "normal", + "fast" + ] + } + } + }, + { + "type": "object", + "title": "SOL legacy single-shot", + "required": [ + "inputMint", + "outputMint", + "amount" + ], + "properties": { + "inputMint": { + "type": "string" + }, + "outputMint": { + "type": "string" + }, + "amount": { + "type": "string" + }, + "slippageBps": { + "type": "integer", + "minimum": 1, + "maximum": 1000, + "default": 50 + } } } ] @@ -521,21 +1382,34 @@ }, "responses": { "200": { - "description": "BSC: { approveTxid?, swapTxid }. TRX/SOL: { txid | signature }", + "description": "Swap broadcast", "content": { "application/json": { "schema": { "type": "object", "properties": { - "success": { "type": "boolean" }, + "success": { + "type": "boolean", + "example": true + }, "data": { "type": "object", "properties": { - "chain": { "type": "string" }, - "approveTxid": { "type": "string", "nullable": true, "description": "BSC only, если token-to-X swap требовал approve" }, - "swapTxid": { "type": "string", "description": "BSC swap txid" }, - "txid": { "type": "string", "description": "TRX txid" }, - "signature": { "type": "string", "description": "SOL tx signature" } + "chain": { + "type": "string", + "example": "BSC" + }, + "approveTxid": { + "type": "string", + "nullable": true + }, + "swapTxid": { + "type": "string" + }, + "signature": { + "type": "string", + "description": "SOL only" + } } } } @@ -543,11 +1417,38 @@ } } }, - "400": { "description": "Invalid pair / slippage / amount / unsupported chain" }, - "404": { "description": "Wallet not found" }, - "409": { "description": "Idempotency-Key reuse with different body, or operation in-flight" }, - "502": { "description": "Swap failed (no liquidity / network error / contract revert)" }, - "503": { "description": "Crypto service not ready" } + "400": { + "description": "Validation error / chain mismatch" + }, + "404": { + "description": "Wallet not found" + }, + "410": { + "description": "Quote expired or not found — request new one via /swap/quote", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "example": false + }, + "error": { + "type": "string", + "example": "Quote expired or not found — request a new one via POST /:chain/swap/quote" + } + } + } + } + } + }, + "502": { + "description": "Upstream RPC / swap failed" + }, + "503": { + "description": "Crypto / audit service unavailable" + } } } }, @@ -555,7 +1456,9 @@ "post": { "summary": "Custodial sign + broadcast Solana tx (2 формата body)", "description": "Custodial sign + broadcast Solana tx. **Два формата body:**\n\n(a) `{ transaction: '' }` — pre-built VersionedTransaction (Jupiter swap, Relay serialized).\n\n(b) `{ instructions[], addressLookupTableAddresses[]? }` — Relay SOL bridge instructions. Server compile'ит `TransactionMessage` → `VersionedTransaction` с `feePayer = user`.\n\n**Security:** валидирует что каждый `isSigner=true` key равен derived user SOL pubkey, resolve LUTs через RPC, partial-sign keypair'ом, broadcast, confirm.", - "tags": ["Wallet Ops"], + "tags": [ + "Wallet Ops" + ], "requestBody": { "required": true, "content": { @@ -565,43 +1468,74 @@ { "title": "Pre-built VersionedTransaction (Jupiter / Relay serialized)", "type": "object", - "required": ["transaction"], + "required": [ + "transaction" + ], "properties": { - "transaction": { "type": "string", "description": "Base64-encoded VersionedTransaction (max ~8KB)" } + "transaction": { + "type": "string", + "description": "Base64-encoded VersionedTransaction (max ~8KB)" + } } }, { "title": "Relay-style instructions (для SOL bridge)", "type": "object", - "required": ["instructions"], + "required": [ + "instructions" + ], "properties": { "instructions": { "type": "array", "description": "Array из {programId, keys, data}. Server compile'ит TransactionMessage → VersionedTransaction с feePayer=user.", "items": { "type": "object", - "required": ["programId", "keys", "data"], + "required": [ + "programId", + "keys", + "data" + ], "properties": { - "programId": { "type": "string", "description": "SPL program pubkey (base58)" }, + "programId": { + "type": "string", + "description": "SPL program pubkey (base58)" + }, "keys": { "type": "array", "items": { "type": "object", - "required": ["pubkey", "isSigner", "isWritable"], + "required": [ + "pubkey", + "isSigner", + "isWritable" + ], "properties": { - "pubkey": { "type": "string", "description": "Account pubkey (base58)" }, - "isSigner": { "type": "boolean", "description": "Если true — pubkey ДОЛЖЕН равняться user'у (anti-drain)" }, - "isWritable": { "type": "boolean" } + "pubkey": { + "type": "string", + "description": "Account pubkey (base58)" + }, + "isSigner": { + "type": "boolean", + "description": "Если true — pubkey ДОЛЖЕН равняться user'у (anti-drain)" + }, + "isWritable": { + "type": "boolean" + } } } }, - "data": { "type": "string", "description": "Instruction data: hex (без префикса) или base64 — autodetect" } + "data": { + "type": "string", + "description": "Instruction data: hex (без префикса) или base64 — autodetect" + } } } }, "addressLookupTableAddresses": { "type": "array", - "items": { "type": "string" }, + "items": { + "type": "string" + }, "description": "Опционально. SPL Address Lookup Table accounts которые server разрезолвит через SOL RPC (getAddressLookupTable)." } } @@ -614,217 +1548,392 @@ "responses": { "200": { "description": "Signed and broadcast", - "content": { "application/json": { "schema": { "type": "object", "properties": { "success": { "type": "boolean" }, "data": { "type": "object", "properties": { "signature": { "type": "string" }, "chain": { "type": "string" } } } } } } } + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean" + }, + "data": { + "type": "object", + "properties": { + "signature": { + "type": "string" + }, + "chain": { + "type": "string" + } + } + } + } + } + } + } }, - "400": { "description": "Invalid body / feePayer mismatch / signer-key mismatch / malformed instruction" }, - "404": { "description": "SOL wallet/mnemonic not found" }, - "502": { "description": "Sign or broadcast failed (включая RPC ошибки / blockhash expired / on-chain revert)" } + "400": { + "description": "Invalid body / feePayer mismatch / signer-key mismatch / malformed instruction" + }, + "404": { + "description": "SOL wallet/mnemonic not found" + }, + "502": { + "description": "Sign or broadcast failed (включая RPC ошибки / blockhash expired / on-chain revert)" + } } } }, - "/btc/utxos/{address}": { "get": { "summary": "Confirmed UTXOs for Bitcoin address", - "tags": ["BTC"], - "parameters": [{ "name": "address", "in": "path", "required": true, "schema": { "type": "string" } }], - "responses": { "200": { "description": "UTXOs" }, "401": { "description": "Not authenticated" } } + "tags": [ + "BTC" + ], + "parameters": [ + { + "name": "address", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "UTXOs" + }, + "401": { + "description": "Not authenticated" + } + } } }, "/btc/fee-estimates": { "get": { "summary": "Bitcoin fee estimates (sat/vB)", - "tags": ["BTC"], - "responses": { "200": { "description": "fast/normal/slow" }, "401": { "description": "Not authenticated" } } + "tags": [ + "BTC" + ], + "responses": { + "200": { + "description": "fast/normal/slow" + }, + "401": { + "description": "Not authenticated" + } + } } }, "/btc/broadcast": { "post": { "summary": "Broadcast raw signed Bitcoin tx", - "tags": ["BTC"], - "requestBody": { "required": true, "content": { "application/json": { "schema": { "type": "object", "required": ["hex"], "properties": { "hex": { "type": "string" } } } } } }, - "responses": { "200": { "description": "txid" }, "400": { "description": "Invalid hex" } } - } - }, - - "/tron/account/{address}": { - "get": { - "summary": "TRON account info + USDT (TRC20) balance", - "tags": ["TRON"], - "parameters": [{ "name": "address", "in": "path", "required": true, "schema": { "type": "string" } }], - "responses": { "200": { "description": "Account data" } } - } - }, - "/tron/createtransaction": { - "post": { - "summary": "Build unsigned TRX transfer", - "tags": ["TRON"], - "requestBody": { "required": true, "content": { "application/json": { "schema": { "type": "object", "required": ["owner_address", "to_address", "amount"], "properties": { "owner_address": { "type": "string" }, "to_address": { "type": "string" }, "amount": { "type": "integer" } } } } } }, - "responses": { "200": { "description": "Unsigned tx" } } - } - }, - "/tron/triggersmartcontract": { - "post": { - "summary": "Build unsigned TRC20 contract call", - "tags": ["TRON"], - "requestBody": { "required": true, "content": { "application/json": { "schema": { "type": "object" } } } }, - "responses": { "200": { "description": "Unsigned tx" } } - } - }, - "/tron/broadcasttransaction": { - "post": { - "summary": "Broadcast signed TRON tx", - "tags": ["TRON"], - "requestBody": { "required": true, "content": { "application/json": { "schema": { "type": "object" } } } }, - "responses": { "200": { "description": "Result" } } - } - }, - - "/sol/swap/quote": { - "get": { - "summary": "Jupiter swap quote (Solana)", - "tags": ["Solana"], - "parameters": [ - { "name": "inputMint", "in": "query", "required": true, "schema": { "type": "string" } }, - { "name": "outputMint", "in": "query", "required": true, "schema": { "type": "string" } }, - { "name": "amount", "in": "query", "required": true, "schema": { "type": "string" } }, - { "name": "slippageBps", "in": "query", "required": true, "schema": { "type": "integer" } } + "tags": [ + "BTC" ], - "responses": { "200": { "description": "Quote" } } - } - }, - "/sol/swap/build": { - "post": { - "summary": "Jupiter swap build", - "tags": ["Solana"], - "requestBody": { "required": true, "content": { "application/json": { "schema": { "type": "object", "required": ["quoteResponse", "userPublicKey"], "properties": { "quoteResponse": { "type": "object" }, "userPublicKey": { "type": "string" } } } } } }, - "responses": { "200": { "description": "Swap tx" } } - } - }, - - "/tron/swap/quote": { - "get": { - "summary": "TRON swap quote (TRX <-> USDT)", - "tags": ["TRON Swap"], - "parameters": [ - { "name": "from", "in": "query", "required": true, "schema": { "type": "string", "enum": ["TRX", "USDT"] } }, - { "name": "to", "in": "query", "required": true, "schema": { "type": "string", "enum": ["TRX", "USDT"] } }, - { "name": "amount", "in": "query", "required": true, "schema": { "type": "string" } } - ], - "responses": { "200": { "description": "Quote" } } - } - }, - "/tron/swap/build": { - "post": { - "summary": "Build TRON swap transactions", - "tags": ["TRON Swap"], - "requestBody": { "required": true, "content": { "application/json": { "schema": { "type": "object", "required": ["from", "to", "amount", "amountOutMin", "userAddress"], "properties": { "from": { "type": "string" }, "to": { "type": "string" }, "amount": { "type": "string" }, "amountOutMin": { "type": "string" }, "userAddress": { "type": "string" } } } } } }, - "responses": { "200": { "description": "Unsigned txs" } } - } - }, - "/tron/swap/broadcast": { - "post": { - "summary": "Broadcast signed TRON swap", - "tags": ["TRON Swap"], - "requestBody": { "required": true, "content": { "application/json": { "schema": { "type": "object", "required": ["signedTransaction"], "properties": { "signedTransaction": { "type": "object" } } } } } }, - "responses": { "200": { "description": "Result" } } - } - }, - - "/bsc/swap/quote": { - "get": { - "summary": "BSC swap quote (PancakeSwap V2)", - "tags": ["BSC"], - "parameters": [ - { "name": "from", "in": "query", "required": true, "schema": { "type": "string", "enum": ["BNB", "USDT", "DOGE"] } }, - { "name": "to", "in": "query", "required": true, "schema": { "type": "string", "enum": ["BNB", "USDT", "DOGE"] } }, - { "name": "amount", "in": "query", "required": true, "schema": { "type": "string" } } - ], - "responses": { "200": { "description": "Quote" } } - } - }, - "/bsc/swap/build": { - "post": { - "summary": "Build BSC swap transactions", - "tags": ["BSC"], - "requestBody": { "required": true, "content": { "application/json": { "schema": { "type": "object", "required": ["from", "to", "amount", "amountOutMin", "userAddress"], "properties": { "from": { "type": "string" }, "to": { "type": "string" }, "amount": { "type": "string" }, "amountOutMin": { "type": "string" }, "userAddress": { "type": "string" } } } } } }, - "responses": { "200": { "description": "Unsigned txs" } } - } - }, - - "/relay/quote": { - "post": { - "summary": "Relay bridge quote (POST с JSON body)", - "description": "Прокси к https://api.relay.link/quote. Параметры в body: user, recipient, originChainId, destinationChainId, originCurrency, destinationCurrency, amount (smallest units), tradeType (EXACT_INPUT|EXACT_OUTPUT).", - "tags": ["Relay"], "requestBody": { "required": true, "content": { "application/json": { "schema": { "type": "object", - "required": ["user", "originChainId", "destinationChainId", "originCurrency", "destinationCurrency", "amount", "tradeType"], + "required": [ + "hex" + ], "properties": { - "user": { "type": "string", "description": "Sender address (0x.. / T.. / SOL pubkey)" }, - "recipient": { "type": "string", "description": "Обычно тот же что user" }, - "originChainId": { "type": "integer", "description": "1=ETH, 56=BSC, 728126428=TRON, 792703809=SOL" }, - "destinationChainId": { "type": "integer" }, - "originCurrency": { "type": "string", "description": "Token address (EVM: 0x.., SOL: mint, TRX: contract или 'TRX')" }, - "destinationCurrency": { "type": "string" }, - "amount": { "type": "string", "description": "smallest units" }, - "tradeType": { "type": "string", "enum": ["EXACT_INPUT", "EXACT_OUTPUT"] } + "hex": { + "type": "string" + } } } } } }, "responses": { - "200": { "description": "Quote с steps[], fees, details, breakdown" }, - "502": { "description": "Relay upstream error (приложен upstream JSON для деталей)" } + "200": { + "description": "txid" + }, + "400": { + "description": "Invalid hex" + } + } + } + }, + "/tron/account/{address}": { + "get": { + "summary": "TRON account info + USDT (TRC20) balance", + "tags": [ + "TRON" + ], + "parameters": [ + { + "name": "address", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Account data" + } + } + } + }, + "/tron/createtransaction": { + "post": { + "summary": "Build unsigned TRX transfer", + "tags": [ + "TRON" + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "owner_address", + "to_address", + "amount" + ], + "properties": { + "owner_address": { + "type": "string" + }, + "to_address": { + "type": "string" + }, + "amount": { + "type": "integer" + } + } + } + } + } + }, + "responses": { + "200": { + "description": "Unsigned tx" + } + } + } + }, + "/tron/triggersmartcontract": { + "post": { + "summary": "Build unsigned TRC20 contract call", + "tags": [ + "TRON" + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object" + } + } + } + }, + "responses": { + "200": { + "description": "Unsigned tx" + } + } + } + }, + "/tron/broadcasttransaction": { + "post": { + "summary": "Broadcast signed TRON tx", + "tags": [ + "TRON" + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object" + } + } + } + }, + "responses": { + "200": { + "description": "Result" + } + } + } + }, + "/relay/quote": { + "post": { + "summary": "Relay bridge quote (POST с JSON body)", + "description": "Прокси к https://api.relay.link/quote. Параметры в body: user, recipient, originChainId, destinationChainId, originCurrency, destinationCurrency, amount (smallest units), tradeType (EXACT_INPUT|EXACT_OUTPUT).", + "tags": [ + "Relay" + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "user", + "originChainId", + "destinationChainId", + "originCurrency", + "destinationCurrency", + "amount", + "tradeType" + ], + "properties": { + "user": { + "type": "string", + "description": "Sender address (0x.. / T.. / SOL pubkey)" + }, + "recipient": { + "type": "string", + "description": "Обычно тот же что user" + }, + "originChainId": { + "type": "integer", + "description": "1=ETH, 56=BSC, 728126428=TRON, 792703809=SOL" + }, + "destinationChainId": { + "type": "integer" + }, + "originCurrency": { + "type": "string", + "description": "Token address (EVM: 0x.., SOL: mint, TRX: contract или 'TRX')" + }, + "destinationCurrency": { + "type": "string" + }, + "amount": { + "type": "string", + "description": "smallest units" + }, + "tradeType": { + "type": "string", + "enum": [ + "EXACT_INPUT", + "EXACT_OUTPUT" + ] + } + } + } + } + } + }, + "responses": { + "200": { + "description": "Quote с steps[], fees, details, breakdown" + }, + "502": { + "description": "Relay upstream error (приложен upstream JSON для деталей)" + } } } }, "/relay/intents/status/v3": { "get": { "summary": "Relay intent status", - "tags": ["Relay"], - "parameters": [{ "name": "requestId", "in": "query", "required": true, "schema": { "type": "string", "description": "Из quote/execute response" } }], - "responses": { "200": { "description": "Status" }, "502": { "description": "Relay upstream error" } } + "tags": [ + "Relay" + ], + "parameters": [ + { + "name": "requestId", + "in": "query", + "required": true, + "schema": { + "type": "string", + "description": "Из quote/execute response" + } + } + ], + "responses": { + "200": { + "description": "Status" + }, + "502": { + "description": "Relay upstream error" + } + } } }, "/relay/execute/{action}": { "post": { "summary": "Relay execute (swap | bridge)", "description": "Принимает ТОТ ЖЕ payload что и /quote и возвращает unsigned tx в steps[].items[].data. Эту tx надо потом подписать (для ETH/BSC — через /wallets/{chain}/sign-raw-evm-tx) и broadcast'нуть. Action whitelist: swap, bridge.", - "tags": ["Relay"], - "parameters": [{ "name": "action", "in": "path", "required": true, "schema": { "type": "string", "enum": ["swap", "bridge"] } }], - "requestBody": { "required": true, "content": { "application/json": { "schema": { "type": "object", "description": "Same as /relay/quote body" } } } }, + "tags": [ + "Relay" + ], + "parameters": [ + { + "name": "action", + "in": "path", + "required": true, + "schema": { + "type": "string", + "enum": [ + "swap", + "bridge" + ] + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "description": "Same as /relay/quote body" + } + } + } + }, "responses": { - "200": { "description": "steps[] with unsigned tx + fees + details" }, - "502": { "description": "Relay upstream error" } + "200": { + "description": "steps[] with unsigned tx + fees + details" + }, + "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"], + "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" } + "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" } + "schema": { + "$ref": "#/components/schemas/Chain" + } } ], "responses": { @@ -832,15 +1941,27 @@ "description": "USD prices", "content": { "application/json": { - "schema": { "$ref": "#/components/schemas/PricesResponse" }, + "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 } + "BTC": { + "usd": 67432.12 + }, + "ETH": { + "usd": 3210.45 + }, + "USDT": { + "usd": 1 + }, + "SOL": { + "usd": 142.88 + }, + "BONK": { + "usd": 0.00002145 + } } } } @@ -848,13 +1969,174 @@ }, "400": { "description": "Validation error: пустой/слишком большой/невалидный список, неизвестный chain или unknown symbol", - "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } + "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" }, - "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" } } } + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, + "/wallets/{chain}/swap/quote": { + "post": { + "summary": "Swap preview / quote (без broadcast)", + "description": "Считает expected output, slippage, network fee, route, approveRequired для custodial swap.\n\n**Read-only** — НЕ broadcast'ит ничего, mnemonic не расшифровывается.\n\nВозвращает `quoteId` (ULID) + preview-снимок. Юзер показывает preview, жмёт Confirm → клиент шлёт `POST /:chain/swap` с `{quoteId}`. Quote живёт **30 секунд** в KeyDB — после execute удаляется (anti-replay).\n\n**Use cases:**\n- Live debounced quote при вводе amount в UI.\n- \"How much would I get?\" — без обязательства execute.\n- Защита от MEV-frontrun: `minOut` зафиксирован между preview и confirm.", + "tags": [ + "Wallet Ops" + ], + "parameters": [ + { + "name": "chain", + "in": "path", + "required": true, + "schema": { + "type": "string", + "enum": [ + "BSC", + "TRX", + "SOL" + ] + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "type": "object", + "title": "BSC/TRX quote (symbols)", + "required": [ + "from", + "to", + "amount" + ], + "properties": { + "from": { + "type": "string", + "example": "USDT", + "description": "BSC: BNB|USDT|USDC|DOGE|WBNB|BUSD; TRX: TRX|USDT" + }, + "to": { + "type": "string", + "example": "BNB" + }, + "amount": { + "type": "string", + "example": "100000000000000000", + "description": "smallest units" + }, + "slippageBps": { + "type": "integer", + "minimum": 1, + "maximum": 1000, + "default": 50 + }, + "feeTier": { + "type": "string", + "enum": [ + "slow", + "normal", + "fast" + ], + "description": "BSC only" + } + } + }, + { + "type": "object", + "title": "SOL quote (mints)", + "required": [ + "inputMint", + "outputMint", + "amount" + ], + "properties": { + "inputMint": { + "type": "string", + "example": "So11111111111111111111111111111111111111112" + }, + "outputMint": { + "type": "string", + "example": "Es9vMFrzaCERmJfrF4H2FYD4KCoNkY11McCe8BenwNYB" + }, + "amount": { + "type": "string", + "example": "1000000", + "description": "smallest units" + }, + "slippageBps": { + "type": "integer", + "minimum": 1, + "maximum": 1000, + "default": 50 + } + } + } + ] + } + } + } + }, + "responses": { + "200": { + "description": "Quote preview", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "example": true + }, + "data": { + "$ref": "#/components/schemas/SwapQuoteResponse" + } + } + } + } + } + }, + "400": { + "description": "Validation error / unsupported pair" + }, + "404": { + "description": "Wallet not found" + }, + "502": { + "description": "Upstream RPC / quote failed (no liquidity, etc.)" + }, + "503": { + "description": "Quote cache unavailable" } } }