From 22059373a43c47c976f517d5bbc244de4ab88539 Mon Sep 17 00:00:00 2001 From: ZOMBIIIIIII <120676065+Metaton241@users.noreply.github.com> Date: Thu, 14 May 2026 19:52:56 +0300 Subject: [PATCH] initjnjnj --- apps/api/src/controllers/wallet.controller.ts | 432 +++++++++++++-- apps/api/src/lib/amount-units.ts | 222 ++++++++ apps/api/src/routes/relay-proxy.routes.ts | 87 ++- apps/api/src/routes/wallet.routes.ts | 8 +- apps/api/swagger.json | 507 +++++++++++++++++- 5 files changed, 1215 insertions(+), 41 deletions(-) create mode 100644 apps/api/src/lib/amount-units.ts diff --git a/apps/api/src/controllers/wallet.controller.ts b/apps/api/src/controllers/wallet.controller.ts index 83ffa65..9d22602 100644 --- a/apps/api/src/controllers/wallet.controller.ts +++ b/apps/api/src/controllers/wallet.controller.ts @@ -17,6 +17,13 @@ import { saveQuote, getQuote, deleteQuote, QUOTE_TTL_SECONDS, type CachedSwapQuo import { generateUlid } from '../utils/ulid'; import { getPricesBySymbols } from '../services/price-oracle.service'; import { SOL_TOKENS } from '../lib/token-registry'; +import { + resolveAmountFromBody, + resolveSendDecimals, + resolveSwapDecimalsBscTrx, + resolveSwapDecimalsSol, + formatSmallestUnits, +} from '../lib/amount-units'; const SOL_WRAPPED_NATIVE_MINT = 'So11111111111111111111111111111111111111112'; function solMintToSymbol(mint: string): string | null { @@ -24,7 +31,7 @@ function solMintToSymbol(mint: string): string | null { const t = SOL_TOKENS.find((x) => x.mint === mint); return t?.symbol ?? null; } -import { getEvmFeeTiers, type FeeTier } from '../services/gas-oracle.service'; +import { getEvmFeeTiers, getEvmFeeForTier, type FeeTier } from '../services/gas-oracle.service'; import { applyEvmTxPolicy } from '../lib/evm-tx-policy'; import { getRelayTrustedAddresses } from '../lib/relay-trusted-cache'; import { acquireSendLock } from '../lib/send-lock'; @@ -35,6 +42,123 @@ import { logger } from '../lib/logger'; const ALLOWED_CHAINS = new Set(ALL_CHAINS); const MAX_TX_LIMIT = 100; +/** + * Per-chain cost-estimate для /send endpoint'а. + * + * Возвращает оценку network fee в native (smallest units + formatted + USD). + * Без mnemonic / RPC broadcast — только gas oracle + price oracle (cached). + * + * Approximations: + * - ETH/BSC: native send = 21000 gas; ERC-20 = 65000 gas. + * - TRX: native send ≈ 5 TRX (5_000_000 sun); USDT ≈ 30 TRX (30_000_000 sun). + * Без actual TronGrid call для bandwidth — approximation. + * - SOL: native ≈ 5_000 lamports; SPL ≈ 5_000 + priority 20_000 ≈ 25_000. + * - BTC: ~140 vbytes × current sat/vB (использует bitcoin-fees-mempool). Здесь упрощено + * до static 5 sat/vB × 140 = 700 satoshi. + */ +interface SendCostEstimate { + fee: { asset: string; amount: string; amountFormatted: string; amountUsd: number | null }; + total: { amountUsd: number | null }; + breakdown: Record; +} +async function computeSendCostEstimate(opts: { + chain: ChainCode; + token?: string; + feeTier: FeeTier; + amountSmallest: string; // используется только для validation (что user не послал 0) +}): Promise { + const { chain, token, feeTier } = opts; + + // EVM ───────────────────────────────────────────── + if (chain === 'ETH' || chain === 'BSC') { + const fee = await getEvmFeeForTier(chain, feeTier); + const gasUnits = token ? 65_000n : 21_000n; + const maxFeeWei = BigInt(fee.maxFeePerGas); + const feeWei = gasUnits * maxFeeWei; + const asset = chain === 'ETH' ? 'ETH' : 'BNB'; + const priceMap = await getPricesBySymbols([{ chain, symbol: asset }]) + .catch(() => new Map()); + const priceUsd = priceMap.get(`${chain}:${asset}`) ?? null; + const amountFormatted = formatSmallestUnits(feeWei.toString(), 18); + let usd: number | null = null; + if (priceUsd != null) { + const big = feeWei; + const divisor = 10n ** 18n; + usd = (Number(big / divisor) + Number(big % divisor) / Number(divisor)) * priceUsd; + } + return { + fee: { asset, amount: feeWei.toString(), amountFormatted, amountUsd: usd }, + total: { amountUsd: usd }, + breakdown: { + gasUnits: gasUnits.toString(), + maxFeePerGasWei: fee.maxFeePerGas, + maxPriorityFeePerGasWei: fee.maxPriorityFeePerGas, + feeTier, + }, + }; + } + + // TRX ────────────────────────────────────────────── + if (chain === 'TRX') { + const feeSun = token ? 30_000_000n : 5_000_000n; // 30 TRX или 5 TRX + const priceMap = await getPricesBySymbols([{ chain, symbol: 'TRX' }]) + .catch(() => new Map()); + const priceUsd = priceMap.get('TRX:TRX') ?? null; + const amountFormatted = formatSmallestUnits(feeSun.toString(), 6); + let usd: number | null = null; + if (priceUsd != null) { + const big = feeSun; const divisor = 10n ** 6n; + usd = (Number(big / divisor) + Number(big % divisor) / Number(divisor)) * priceUsd; + } + return { + fee: { asset: 'TRX', amount: feeSun.toString(), amountFormatted, amountUsd: usd }, + total: { amountUsd: usd }, + breakdown: { tokenTransfer: !!token, note: 'TRX energy/bandwidth approximation' }, + }; + } + + // SOL ────────────────────────────────────────────── + if (chain === 'SOL') { + const feeLamports = token ? 25_000n : 5_000n; + const priceMap = await getPricesBySymbols([{ chain, symbol: 'SOL' }]) + .catch(() => new Map()); + const priceUsd = priceMap.get('SOL:SOL') ?? null; + const amountFormatted = formatSmallestUnits(feeLamports.toString(), 9); + let usd: number | null = null; + if (priceUsd != null) { + const big = feeLamports; const divisor = 10n ** 9n; + usd = (Number(big / divisor) + Number(big % divisor) / Number(divisor)) * priceUsd; + } + return { + fee: { asset: 'SOL', amount: feeLamports.toString(), amountFormatted, amountUsd: usd }, + total: { amountUsd: usd }, + breakdown: { signatureFee: '5000', priorityFee: token ? '20000' : '0' }, + }; + } + + // BTC ────────────────────────────────────────────── + if (chain === 'BTC') { + // 140 vbytes * 5 sat/vB = 700 satoshi (рassive approximation). + const feeSatoshi = 700n; + const priceMap = await getPricesBySymbols([{ chain, symbol: 'BTC' }]) + .catch(() => new Map()); + const priceUsd = priceMap.get('BTC:BTC') ?? null; + const amountFormatted = formatSmallestUnits(feeSatoshi.toString(), 8); + let usd: number | null = null; + if (priceUsd != null) { + const big = feeSatoshi; const divisor = 10n ** 8n; + usd = (Number(big / divisor) + Number(big % divisor) / Number(divisor)) * priceUsd; + } + return { + fee: { asset: 'BTC', amount: feeSatoshi.toString(), amountFormatted, amountUsd: usd }, + total: { amountUsd: usd }, + breakdown: { vbytes: '140', satPerVByte: '5' }, + }; + } + + throw new Error(`computeSendCostEstimate: unsupported chain ${chain}`); +} + class ConflictError extends Error { constructor() { super('Wallet already exists'); } } @@ -347,16 +471,12 @@ export const WalletController = { return; } - const { to, amount, token, feeTier } = req.body ?? {}; + const { to, amount, amountHuman, token, feeTier } = req.body ?? {}; if (!isValidAddress(chain, String(to))) { res.status(400).json({ success: false, error: 'Invalid recipient address for chain' }); return; } - if (!isValidAmount(String(amount))) { - res.status(400).json({ success: false, error: 'Invalid amount (must be positive integer string)' }); - return; - } let normalizedToken: string | undefined; if (token !== undefined && token !== null) { @@ -378,6 +498,22 @@ export const WalletController = { normalizedFeeTier = feeTier; } + // Resolve amount: backward-compat'ный {amount: "smallest"} ИЛИ новый {amountHuman: "0.01"}. + // Throws на conflict / missing / invalid format. + let resolvedAmount: string; + try { + const dec = resolveSendDecimals(chain, normalizedToken); + resolvedAmount = resolveAmountFromBody({ amount, amountHuman }, dec); + } catch (err: any) { + res.status(400).json({ success: false, error: err.message }); + return; + } + // Дополнительная страховка: smallest-units должен пройти isValidAmount (positive integer). + if (!isValidAmount(resolvedAmount)) { + res.status(400).json({ success: false, error: 'Invalid amount (must resolve to positive integer)' }); + return; + } + // C3 — idempotency. Если client передал Idempotency-Key — проверяем retry. const idempKey = extractIdempotencyKey(req.headers['idempotency-key']); if (idempKey) { @@ -433,7 +569,7 @@ export const WalletController = { chain, mnemonic, to: String(to), - amount: String(amount), + amount: resolvedAmount, token: normalizedToken, expectedFromAddress: wallet.address, feeTier: normalizedFeeTier, @@ -696,47 +832,71 @@ export const WalletController = { 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' }); + const { from, to, amount, amountHuman, slippageBps, feeTier } = req.body ?? {}; + if (typeof from !== 'string' || typeof to !== 'string') { + res.status(400).json({ success: false, error: 'BSC quote body: {from, to} required as strings' }); + return; + } + let resolvedAmount: string; + try { + const dec = resolveSwapDecimalsBscTrx('BSC', from); + resolvedAmount = resolveAmountFromBody({ amount, amountHuman }, dec); + } catch (err: any) { + res.status(400).json({ success: false, error: err.message }); return; } raw = await quoteBsc({ fromAddress: wallet.address, - from, to, amount, + from, to, amount: resolvedAmount, slippageBps: typeof slippageBps === 'number' ? slippageBps : undefined, feeTier: feeTier === 'slow' || feeTier === 'normal' || feeTier === 'fast' ? feeTier : undefined, }); cacheParams = { - from, to, amount, + from, to, amount: resolvedAmount, 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' }); + const { from, to, amount, amountHuman, slippageBps } = req.body ?? {}; + if (typeof from !== 'string' || typeof to !== 'string') { + res.status(400).json({ success: false, error: 'TRX quote body: {from, to} required as strings' }); + return; + } + let resolvedAmount: string; + try { + const dec = resolveSwapDecimalsBscTrx('TRX', from); + resolvedAmount = resolveAmountFromBody({ amount, amountHuman }, dec); + } catch (err: any) { + res.status(400).json({ success: false, error: err.message }); return; } raw = await quoteTrx({ fromAddress: wallet.address, - from, to, amount, + from, to, amount: resolvedAmount, slippageBps: typeof slippageBps === 'number' ? slippageBps : undefined, }); - cacheParams = { from, to, amount, slippageBps: raw.slippageBps }; + cacheParams = { from, to, amount: resolvedAmount, 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' }); + const { inputMint, outputMint, amount, amountHuman, slippageBps } = req.body ?? {}; + if (typeof inputMint !== 'string' || typeof outputMint !== 'string') { + res.status(400).json({ success: false, error: 'SOL quote body: {inputMint, outputMint} required as strings' }); + return; + } + let resolvedAmount: string; + try { + const dec = resolveSwapDecimalsSol(inputMint); + resolvedAmount = resolveAmountFromBody({ amount, amountHuman }, dec); + } catch (err: any) { + res.status(400).json({ success: false, error: err.message }); return; } const solQuote = await quoteSol({ - inputMint, outputMint, amount, + inputMint, outputMint, amount: resolvedAmount, slippageBps: typeof slippageBps === 'number' ? slippageBps : undefined, }); raw = solQuote; lockedJupiterQuote = solQuote.jupiterQuoteResponse; - cacheParams = { inputMint, outputMint, amount, slippageBps: solQuote.slippageBps }; + cacheParams = { inputMint, outputMint, amount: resolvedAmount, slippageBps: solQuote.slippageBps }; } // USD enrichment (graceful — null если CoinGecko недоступен). @@ -952,42 +1112,52 @@ export const WalletController = { 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: {quoteId} OR {from, to, amount} required'); + const { from, to, amount, amountHuman, slippageBps, feeTier } = params; + if (typeof from !== 'string' || typeof to !== 'string') { + throw new Error('BSC swap body: {quoteId} OR {from, to, amount|amountHuman} required'); } + // cachedQuote → amount уже resolved (smallest units). Legacy body → resolve amountHuman если задан. + const amountForExec = cachedQuote + ? amount + : resolveAmountFromBody({ amount, amountHuman }, resolveSwapDecimalsBscTrx('BSC', from)); result = await executeBsc({ mnemonic, expectedFromAddress: wallet.address, - from, to, amount, + from, to, amount: amountForExec, slippageBps: typeof slippageBps === 'number' ? slippageBps : undefined, feeTier: feeTier === 'slow' || feeTier === 'normal' || feeTier === 'fast' ? feeTier : undefined, lockedMinOut: cachedQuote?.locked.minOut, }); } else if (chain === 'TRX') { 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: {quoteId} OR {from, to, amount} required'); + const { from, to, amount, amountHuman, slippageBps } = params; + if (typeof from !== 'string' || typeof to !== 'string') { + throw new Error('TRX swap body: {quoteId} OR {from, to, amount|amountHuman} required'); } + const amountForExec = cachedQuote + ? amount + : resolveAmountFromBody({ amount, amountHuman }, resolveSwapDecimalsBscTrx('TRX', from)); result = await executeTrx({ mnemonic, expectedFromAddress: wallet.address, - from, to, amount, + from, to, amount: amountForExec, slippageBps: typeof slippageBps === 'number' ? slippageBps : undefined, lockedMinOut: cachedQuote?.locked.minOut, }); } else { // SOL Jupiter 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: {quoteId} OR {inputMint, outputMint, amount} required'); + const { inputMint, outputMint, amount, amountHuman, slippageBps } = params; + if (typeof inputMint !== 'string' || typeof outputMint !== 'string') { + throw new Error('SOL swap body: {quoteId} OR {inputMint, outputMint, amount|amountHuman} required'); } + const amountForExec = cachedQuote + ? amount + : resolveAmountFromBody({ amount, amountHuman }, resolveSwapDecimalsSol(inputMint)); result = await executeSol({ mnemonic, expectedFromAddress: wallet.address, - inputMint, outputMint, amount, + inputMint, outputMint, amount: amountForExec, slippageBps: typeof slippageBps === 'number' ? slippageBps : undefined, jupiterQuoteResponse: cachedQuote?.locked.jupiterQuoteResponse, }); @@ -1026,6 +1196,200 @@ export const WalletController = { } }, + /** + * POST /api/wallets/:chain/send/cost-estimate + * + * Read-only USD-оценка сколько будет стоить broadcast tx (gas/network fee). + * НЕ резервирует idempotency cache, НЕ дёргает mnemonic, НЕ broadcast'ит. + * + * Body: тот же что у /send минус `to` (и amount ИЛИ amountHuman): + * { token?, amount?, amountHuman?, feeTier? } + */ + async estimateSendCost(req: Request, res: Response) { + const chain = String(req.params.chain).toUpperCase(); + if (!isChain(chain)) { + res.status(400).json({ success: false, error: 'Invalid chain parameter' }); + return; + } + + const { token, amount, amountHuman, feeTier } = req.body ?? {}; + + // Validate token (если задан) + resolve decimals (для validation amountHuman). + let normalizedToken: string | undefined; + if (token !== undefined && token !== null && token !== '') { + if (typeof token !== 'string' || !/^[A-Za-z0-9]{1,10}$/.test(token)) { + res.status(400).json({ success: false, error: 'Invalid token symbol format' }); + return; + } + normalizedToken = token.toUpperCase(); + } + + let resolvedAmount: string; + try { + const dec = resolveSendDecimals(chain, normalizedToken); + resolvedAmount = resolveAmountFromBody({ amount, amountHuman }, dec); + } catch (err: any) { + res.status(400).json({ success: false, error: err.message }); + return; + } + if (!isValidAmount(resolvedAmount)) { + res.status(400).json({ success: false, error: 'Invalid amount' }); + return; + } + + let normalizedFeeTier: FeeTier = 'normal'; + if (feeTier !== undefined && feeTier !== null && feeTier !== '') { + if (feeTier !== 'slow' && feeTier !== 'normal' && feeTier !== 'fast') { + res.status(400).json({ success: false, error: 'Invalid feeTier (must be slow|normal|fast)' }); + return; + } + normalizedFeeTier = feeTier; + } + + try { + const estimate = await computeSendCostEstimate({ + chain, + token: normalizedToken, + feeTier: normalizedFeeTier, + amountSmallest: resolvedAmount, + }); + res.json({ success: true, data: { chain, ...estimate } }); + } catch (err: any) { + logger.error(`estimateSendCost ${chain} failed: ${err.stack || err.message}`); + res.status(502).json({ success: false, error: err?.message?.slice?.(0, 200) || 'Estimate failed' }); + } + }, + + /** + * POST /api/wallets/:chain/swap/cost-estimate + * + * Те же поля что у /swap/quote, но возвращает ТОЛЬКО fee + route + approveRequired + * (без quoteId + cache). Идемпотентно, без побочек. + */ + async estimateSwapCost(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: 'Cost-estimate 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 feeAssetSymbolForPrice: string; + + if (chain === 'BSC') { + const { from, to, amount, amountHuman, slippageBps, feeTier } = req.body ?? {}; + if (typeof from !== 'string' || typeof to !== 'string') { + res.status(400).json({ success: false, error: 'BSC body: {from, to, amount|amountHuman} required' }); + return; + } + let resolved: string; + try { + const dec = resolveSwapDecimalsBscTrx('BSC', from); + resolved = resolveAmountFromBody({ amount, amountHuman }, dec); + } catch (err: any) { + res.status(400).json({ success: false, error: err.message }); + return; + } + raw = await quoteBsc({ + fromAddress: wallet.address, + from, to, amount: resolved, + slippageBps: typeof slippageBps === 'number' ? slippageBps : undefined, + feeTier: feeTier === 'slow' || feeTier === 'normal' || feeTier === 'fast' ? feeTier : undefined, + }); + feeAssetSymbolForPrice = 'BNB'; + } else if (chain === 'TRX') { + const { from, to, amount, amountHuman, slippageBps } = req.body ?? {}; + if (typeof from !== 'string' || typeof to !== 'string') { + res.status(400).json({ success: false, error: 'TRX body: {from, to, amount|amountHuman} required' }); + return; + } + let resolved: string; + try { + const dec = resolveSwapDecimalsBscTrx('TRX', from); + resolved = resolveAmountFromBody({ amount, amountHuman }, dec); + } catch (err: any) { + res.status(400).json({ success: false, error: err.message }); + return; + } + raw = await quoteTrx({ + fromAddress: wallet.address, + from, to, amount: resolved, + slippageBps: typeof slippageBps === 'number' ? slippageBps : undefined, + }); + feeAssetSymbolForPrice = 'TRX'; + } else { + const { inputMint, outputMint, amount, amountHuman, slippageBps } = req.body ?? {}; + if (typeof inputMint !== 'string' || typeof outputMint !== 'string') { + res.status(400).json({ success: false, error: 'SOL body: {inputMint, outputMint, amount|amountHuman} required' }); + return; + } + let resolved: string; + try { + const dec = resolveSwapDecimalsSol(inputMint); + resolved = resolveAmountFromBody({ amount, amountHuman }, dec); + } catch (err: any) { + res.status(400).json({ success: false, error: err.message }); + return; + } + raw = await quoteSol({ + inputMint, outputMint, amount: resolved, + slippageBps: typeof slippageBps === 'number' ? slippageBps : undefined, + }); + feeAssetSymbolForPrice = 'SOL'; + } + + // USD enrichment fee asset + const priceMap = await getPricesBySymbols([ + { chain, symbol: feeAssetSymbolForPrice }, + ]).catch(() => new Map()); + const feePriceUsd = priceMap.get(`${chain}:${feeAssetSymbolForPrice}`) ?? null; + + const feeAssetDecimals = raw.networkFee.asset === 'TRX' ? 6 + : raw.networkFee.asset === 'SOL' ? 9 + : 18; + const amountFormatted = formatSmallestUnits(raw.networkFee.amount, feeAssetDecimals); + let amountUsd: number | null = null; + if (feePriceUsd != null) { + try { + const big = BigInt(raw.networkFee.amount); + const divisor = 10n ** BigInt(feeAssetDecimals); + const whole = Number(big / divisor); + const frac = Number(big % divisor) / Number(divisor); + amountUsd = (whole + frac) * feePriceUsd; + } catch { amountUsd = null; } + } + + res.json({ + success: true, + data: { + chain, + fee: { + asset: raw.networkFee.asset, + amount: raw.networkFee.amount, + amountFormatted, + amountUsd, + }, + total: { amountUsd }, + route: raw.route, + approveRequired: raw.approveRequired, + estimatedGasUnits: raw.estimatedGasUnits ?? null, + slippageBps: raw.slippageBps, + }, + }); + } catch (err: any) { + logger.error(`estimateSwapCost ${chain} failed: ${err.stack || err.message}`); + res.status(502).json({ success: false, error: err?.message?.slice?.(0, 200) || 'Estimate failed' }); + } + }, + /** * POST /api/wallets/SOL/sign-and-broadcast-tx — custodial sign + broadcast для arbitrary serialized SOL tx. * Используется для Relay bridge SOL-side (когда Relay /execute возвращает unsigned Solana tx). diff --git a/apps/api/src/lib/amount-units.ts b/apps/api/src/lib/amount-units.ts new file mode 100644 index 0000000..cb140f6 --- /dev/null +++ b/apps/api/src/lib/amount-units.ts @@ -0,0 +1,222 @@ +/** + * Amount unit utilities. + * + * API контракт исторически — `amount: string` в smallest-units (wei/lamports/sun/satoshi). + * Этот файл добавляет ОПЦИОНАЛЬНЫЙ парсинг `amountHuman: "0.01"` через token decimals из + * `token-registry`. Старое поле `amount` остаётся 100% backward-compatible. + * + * Все вычисления — BigInt-based, без float'ов (для finance: precision critical). + * + * Используется в: + * - sendFromChain (body: {amount | amountHuman, token?}) + * - quoteSwap / swapOnChain legacy (body: {from/inputMint, amount | amountHuman}) + * - relay-proxy /quote preprocessing (body: {originCurrency, amount | amountHuman}) + * - cost-estimate endpoints (body: same) + */ + +import type { ChainCode } from './address-validators'; +import { + getTokenInfo, + getEvmTokens, + TRX_TOKENS, + SOL_TOKENS, +} from './token-registry'; + +/** + * Native decimals per chain. Дублирует `NATIVE_DECIMALS` из `wallet-ops.service.ts` + * чтобы избежать circular dep (wallet-ops импортирует address-validators которое + * импортирует этот файл косвенно). + */ +export const NATIVE_DECIMALS: Record = { + ETH: 18, + BSC: 18, + BTC: 8, + TRX: 6, + SOL: 9, +}; + +const SOL_WRAPPED_NATIVE_MINT = 'So11111111111111111111111111111111111111112'; + +/** + * Парсит "0.01" → "10000000" (для SOL 9 decimals) через BigInt. + * + * Правила: + * - "10" + dec=6 → "10000000" (integer, без точки) + * - "0.01" + dec=6 → "10000" (1 + 4 zeros) + * - "1.5" + dec=6 → "1500000" + * - "0.000001" + dec=6 → "1" + * - "0.1234567" + dec=6 → "123456" (truncate — не round; consistent с frontend parseAmount) + * - "0" + → throw (zero amount = error per existing isValidAmount) + * - "-1" / "1e3" + → throw + * - "0.1" + dec=0 → throw "no fractional digits for 0-decimal token" + */ +export function parseHumanAmount(human: string, decimals: number): string { + if (typeof human !== 'string') { + throw new Error('amountHuman must be a string'); + } + const s = human.trim(); + if (!s) throw new Error('amountHuman is empty'); + // Defense-in-depth: длинная строка (например `"1" + "0".repeat(10000)`) форсирует + // O(n) парсинг + BigInt round-trip. 64KB body-limit (express.json) — общий gate; + // этот check — specific для нового парсера. Legit amount'ы укладываются в 80 chars + // (36-decimal fractional + integer + dot). Атакующий не сможет drain CPU. + if (s.length > 80) { + throw new Error('amountHuman too long (max 80 chars)'); + } + if (!/^\d+(\.\d+)?$/.test(s)) { + throw new Error(`amountHuman invalid format "${s}" (expected "1" or "0.01")`); + } + if (!Number.isInteger(decimals) || decimals < 0 || decimals > 36) { + throw new Error(`Invalid decimals ${decimals}`); + } + + const [whole, frac = ''] = s.split('.'); + if (decimals === 0 && frac.length > 0) { + throw new Error(`This token has 0 decimals, use integer amount (got "${s}")`); + } + // Truncate (not round) лишние цифры дробной части. + const fracTrunc = frac.slice(0, decimals); + const padded = fracTrunc.padEnd(decimals, '0'); + // Strip leading zeros чтобы получился чистый BigInt-friendly string. + const result = (whole + padded).replace(/^0+/, '') || '0'; + + if (result === '0') { + throw new Error(`amountHuman "${s}" evaluates to 0 smallest units`); + } + return result; +} + +/** + * Inverse of parseHumanAmount. "10000000" + dec=6 → "10". + * Используется для логирования / formatted output в cost-estimate. + */ +export function formatSmallestUnits(smallest: string, decimals: number): string { + if (typeof smallest !== 'string' || !/^\d+$/.test(smallest)) { + return '0'; + } + if (decimals === 0) return smallest; + if (smallest.length <= decimals) { + const padded = smallest.padStart(decimals + 1, '0'); + const whole = padded.slice(0, padded.length - decimals); + const frac = padded.slice(padded.length - decimals).replace(/0+$/, ''); + return frac ? `${whole}.${frac}` : whole; + } + const whole = smallest.slice(0, smallest.length - decimals); + const frac = smallest.slice(smallest.length - decimals).replace(/0+$/, ''); + return frac ? `${whole}.${frac}` : whole; +} + +/** + * Decimals для send endpoint'а. + * - token задан → token-registry lookup (case-insensitive) + * - token пуст → native decimals + */ +export function resolveSendDecimals(chain: ChainCode, token?: string | null): number { + if (!token) return NATIVE_DECIMALS[chain]; + const info = getTokenInfo(chain, token); + if (!info) { + throw new Error(`Unknown token "${token}" on ${chain} — cannot resolve decimals`); + } + return info.decimals; +} + +/** + * Decimals для BSC/TRX swap (`from` symbol). + * - "BNB" / "TRX" → native (18 / 6) + * - "USDT" / etc → registry lookup + */ +export function resolveSwapDecimalsBscTrx(chain: 'BSC' | 'TRX', symbol: string): number { + const upper = symbol.toUpperCase(); + if (upper === chain) return NATIVE_DECIMALS[chain]; // BSC→BNB через chain code не сработает, + // но BNB→18 и TRX→6 совпадают с NATIVE_DECIMALS, поэтому fallback ниже работает. + if (chain === 'BSC' && upper === 'BNB') return NATIVE_DECIMALS.BSC; + if (chain === 'TRX' && upper === 'TRX') return NATIVE_DECIMALS.TRX; + const info = getTokenInfo(chain, upper); + if (!info) { + throw new Error(`Unknown ${chain} token "${symbol}" — cannot resolve decimals`); + } + return info.decimals; +} + +/** + * Decimals для SOL swap (`inputMint`). + * Wrapped SOL = 9; иначе SOL_TOKENS lookup по mint. + */ +export function resolveSwapDecimalsSol(mint: string): number { + if (mint === SOL_WRAPPED_NATIVE_MINT) return NATIVE_DECIMALS.SOL; + const t = SOL_TOKENS.find((x) => x.mint === mint); + if (!t) { + throw new Error(`Unknown SOL mint "${mint}" — cannot resolve decimals`); + } + return t.decimals; +} + +/** + * Decimals по contract address (для Relay /quote где body содержит + * `originCurrency: "0x..."` вместо symbol). Returns null если не найден — + * caller решает: 400 vs fallback. + */ +export function getDecimalsByContract(chain: ChainCode, contractOrMint: string): number | null { + const addr = contractOrMint.trim(); + if (!addr) return null; + + // Native sentinels (Relay использует 0xeeee... для native EVM). + if (chain === 'SOL' && addr === SOL_WRAPPED_NATIVE_MINT) return NATIVE_DECIMALS.SOL; + if ((chain === 'ETH' || chain === 'BSC') && + /^0xee+/i.test(addr) || addr === '0x0000000000000000000000000000000000000000') { + return NATIVE_DECIMALS[chain]; + } + if (chain === 'TRX' && (addr === 'TRX' || addr === '0x0000000000000000000000000000000000000000')) { + return NATIVE_DECIMALS.TRX; + } + + if (chain === 'ETH' || chain === 'BSC') { + const lower = addr.toLowerCase(); + const t = getEvmTokens(chain).find((x) => x.contractAddress.toLowerCase() === lower); + return t?.decimals ?? null; + } + if (chain === 'TRX') { + const t = TRX_TOKENS.find((x) => x.contractAddress === addr); + return t?.decimals ?? null; + } + if (chain === 'SOL') { + const t = SOL_TOKENS.find((x) => x.mint === addr); + return t?.decimals ?? null; + } + return null; +} + +/** + * Main dispatcher. Body содержит ровно ОДНО поле из {amount, amountHuman}. + * - оба пусты → throw + * - оба заданы → throw "use either … not both" (поведение из плана) + * - amount задан → возврат as-is (legacy backward-compat) + * - amountHuman задан → parseHumanAmount(value, decimals) + * + * Caller передаёт `decimals` (уже resolved через resolveSendDecimals / resolveSwapDecimals*). + */ +export function resolveAmountFromBody( + body: { amount?: unknown; amountHuman?: unknown }, + decimals: number, +): string { + const hasAmount = body?.amount !== undefined && body?.amount !== null && body?.amount !== ''; + const hasAmountHuman = body?.amountHuman !== undefined && body?.amountHuman !== null && body?.amountHuman !== ''; + + if (hasAmount && hasAmountHuman) { + throw new Error('Use either "amount" (smallest units) OR "amountHuman" (human form), not both'); + } + if (!hasAmount && !hasAmountHuman) { + throw new Error('Either "amount" or "amountHuman" is required'); + } + + if (hasAmount) { + const a = String(body.amount); + if (!/^\d+$/.test(a) || BigInt(a) <= 0n) { + throw new Error('amount must be positive integer string (smallest units)'); + } + return a; + } + + // amountHuman path + return parseHumanAmount(String(body.amountHuman), decimals); +} diff --git a/apps/api/src/routes/relay-proxy.routes.ts b/apps/api/src/routes/relay-proxy.routes.ts index 15acac7..7552d09 100644 --- a/apps/api/src/routes/relay-proxy.routes.ts +++ b/apps/api/src/routes/relay-proxy.routes.ts @@ -5,6 +5,7 @@ import { WalletModel } from '../models/wallet.model'; import type { ChainCode } from '../lib/address-validators'; import { indexRelayExecuteResponse } from '../lib/relay-trusted-cache'; import { proxiedFetch } from '../lib/outbound-proxy'; +import { getDecimalsByContract, parseHumanAmount } from '../lib/amount-units'; const router = Router(); const RELAY_API_URL = 'https://api.relay.link'; @@ -21,7 +22,9 @@ const RELAY_CHAINID_TO_CHAIN: Record = { // Без него `req.path` после `/execute/` свободен → `/execute/../admin` обходит startsWith-check. // Relay API: `POST /quote` (раньше был `GET /quote/v2` — upstream сменили в 2025). const ALLOWED_GET_PATHS = new Set(['/intents/status/v3']); -const ALLOWED_POST_PATHS = new Set(['/quote']); +// `/cost-estimate` — LOCAL alias (not a Relay endpoint). Internally calls Relay /quote и +// фильтрует response — отдаёт только fees + details (без steps[]). +const ALLOWED_POST_PATHS = new Set(['/quote', '/cost-estimate']); const ALLOWED_EXECUTE_ACTIONS = new Set([ 'swap', 'bridge', @@ -55,10 +58,14 @@ async function proxyRelayRequest(req: Request, res: Response, next: NextFunction return; } + // Detect local-only /cost-estimate endpoint — internally forwarded к Relay /quote, + // response trimmed (без steps[]). + const isCostEstimate = req.method === 'POST' && relayPath === '/cost-estimate'; + // C16 — bind body.user / body.recipient to JWT user's wallet. // Без этого authenticated user может set recipient=attacker → Relay строит quote → // victim signs → bridge funds к attacker'у. - if (req.method === 'POST' && (relayPath === '/quote' || relayPath.startsWith('/execute/'))) { + if (req.method === 'POST' && (relayPath === '/quote' || relayPath === '/cost-estimate' || relayPath.startsWith('/execute/'))) { const userId = (req as any).auth?.userId; if (!userId) { res.status(401).json({ success: false, error: 'auth required' }); @@ -114,7 +121,45 @@ async function proxyRelayRequest(req: Request, res: Response, next: NextFunction } } - const relayUrl = new URL(`${RELAY_API_URL}${relayPath}`); + // ADDITIVE: amountHuman preprocessing для /quote, /cost-estimate, /execute/*. + // Если body содержит amountHuman → разрешаем через originCurrency contract → decimals. + // Старое поле `amount` (smallest units) продолжает работать unchanged. + if (req.method === 'POST' && + (relayPath === '/quote' || relayPath === '/cost-estimate' || relayPath.startsWith('/execute/'))) { + const body = req.body ?? {}; + const hasAmount = body.amount !== undefined && body.amount !== null && body.amount !== ''; + const hasAmountHuman = body.amountHuman !== undefined && body.amountHuman !== null && body.amountHuman !== ''; + if (hasAmount && hasAmountHuman) { + res.status(400).json({ success: false, error: 'Use either "amount" or "amountHuman", not both' }); + return; + } + if (hasAmountHuman) { + const originCurrency = String(body.originCurrency ?? ''); + const originChainId = Number(body.originChainId); + const originChain = RELAY_CHAINID_TO_CHAIN[originChainId]; + if (!originChain) { + res.status(400).json({ success: false, error: `originChainId ${originChainId} not in allowlist (needed для amountHuman → decimals)` }); + return; + } + const dec = getDecimalsByContract(originChain, originCurrency); + if (dec == null) { + res.status(400).json({ success: false, error: `Unknown originCurrency "${originCurrency}" — supply "amount" (smallest units) directly` }); + return; + } + try { + const resolved = parseHumanAmount(String(body.amountHuman), dec); + req.body.amount = resolved; + delete req.body.amountHuman; + } catch (err: any) { + res.status(400).json({ success: false, error: err.message }); + return; + } + } + } + + // Map local /cost-estimate → real Relay /quote endpoint. + const upstreamPath = isCostEstimate ? '/quote' : relayPath; + const relayUrl = new URL(`${RELAY_API_URL}${upstreamPath}`); Object.entries(req.query).forEach(([key, value]) => { if (Array.isArray(value)) { @@ -169,6 +214,42 @@ async function proxyRelayRequest(req: Request, res: Response, next: NextFunction return; } + // /cost-estimate — trim response (только fees + details, без steps[]). + if (isCostEstimate) { + let trimmed: any; + try { + const full = JSON.parse(text); + const fees = full?.fees ?? {}; + let totalUsd: number | null = 0; + for (const k of ['gas', 'relayer', 'app']) { + const u = Number(fees?.[k]?.amountUsd); + if (Number.isFinite(u)) totalUsd += u; + else { totalUsd = null; break; } + } + trimmed = { + success: true, + data: { + fees: { + gas: fees.gas ?? null, + relayer: fees.relayer ?? null, + app: fees.app ?? null, + total: { amountUsd: totalUsd }, + }, + rate: full?.details?.rate ?? null, + priceImpactPct: full?.details?.totalImpact?.percent ?? null, + priceImpactUsd: full?.details?.totalImpact?.usd ?? null, + timeEstimate: full?.details?.timeEstimate ?? null, + currencyIn: full?.details?.currencyIn ?? null, + currencyOut: full?.details?.currencyOut ?? null, + }, + }; + } catch { + trimmed = { success: false, error: 'Relay returned non-JSON for /cost-estimate' }; + } + res.json(trimmed); + return; + } + // Send raw text если это валидный JSON, иначе обернём try { res.send(text); diff --git a/apps/api/src/routes/wallet.routes.ts b/apps/api/src/routes/wallet.routes.ts index 9e53acc..6e7ca4a 100644 --- a/apps/api/src/routes/wallet.routes.ts +++ b/apps/api/src/routes/wallet.routes.ts @@ -13,11 +13,15 @@ router.get('/portfolio', WalletController.getPortfolio); router.get('/:chain/balance', WalletController.getChainBalance); router.get('/:chain/transactions', WalletController.getChainTransactions); router.get('/:chain/gas-suggestions', WalletController.getGasSuggestions); +// IMPORTANT: more specific paths ДОЛЖНЫ быть зарегистрированы РАНЬШЕ — Express сматчит first. +// /:chain/send/cost-estimate ПЕРЕД /:chain/send +// /:chain/swap/quote ПЕРЕД /:chain/swap +// /:chain/swap/cost-estimate ПЕРЕД /:chain/swap +router.post('/:chain/send/cost-estimate', WalletController.estimateSendCost); 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/cost-estimate', WalletController.estimateSwapCost); router.post('/:chain/swap', WalletController.swapOnChain); router.post('/SOL/sign-and-broadcast-tx', WalletController.signSolanaTx); diff --git a/apps/api/swagger.json b/apps/api/swagger.json index ffc7790..fbd42d2 100644 --- a/apps/api/swagger.json +++ b/apps/api/swagger.json @@ -426,8 +426,7 @@ "SendRequest": { "type": "object", "required": [ - "to", - "amount" + "to" ], "properties": { "to": { @@ -452,6 +451,11 @@ ], "nullable": true, "description": "Default 'normal'. ETH/BSC: eth_feeHistory p25/p50/p75 priority. BTC: blockstream targets 144/6/1 блок. TRX/SOL: игнорится." + }, + "amountHuman": { + "type": "string", + "description": "Human-readable amount (e.g. \"0.01\"). Server конвертит в smallest units через token decimals. Используется ВМЕСТО поля `amount` — НЕ передавай оба одновременно.", + "example": "0.01" } } }, @@ -702,6 +706,196 @@ "description": "EVM gas units (BSC). Null для TRX/SOL." } } + }, + "SendCostEstimateResponse": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "example": true + }, + "data": { + "type": "object", + "properties": { + "chain": { + "type": "string", + "example": "BSC" + }, + "fee": { + "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 + } + } + }, + "breakdown": { + "type": "object", + "additionalProperties": true + } + } + } + } + }, + "SwapCostEstimateResponse": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "example": true + }, + "data": { + "type": "object", + "properties": { + "chain": { + "type": "string", + "example": "BSC" + }, + "fee": { + "type": "object", + "properties": { + "asset": { + "type": "string", + "example": "BNB" + }, + "amount": { + "type": "string" + }, + "amountFormatted": { + "type": "string" + }, + "amountUsd": { + "type": "number", + "nullable": true + } + } + }, + "total": { + "type": "object", + "properties": { + "amountUsd": { + "type": "number", + "nullable": true + } + } + }, + "route": { + "type": "array", + "items": { + "type": "string" + }, + "example": [ + "USDT", + "BNB" + ] + }, + "approveRequired": { + "type": "boolean" + }, + "estimatedGasUnits": { + "type": "string", + "nullable": true + }, + "slippageBps": { + "type": "integer" + } + } + } + } + }, + "BridgeCostEstimateResponse": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "example": true + }, + "data": { + "type": "object", + "properties": { + "fees": { + "type": "object", + "properties": { + "gas": { + "type": "object", + "nullable": true, + "additionalProperties": true + }, + "relayer": { + "type": "object", + "nullable": true, + "additionalProperties": true + }, + "app": { + "type": "object", + "nullable": true, + "additionalProperties": true + }, + "total": { + "type": "object", + "properties": { + "amountUsd": { + "type": "number", + "nullable": true + } + } + } + } + }, + "rate": { + "type": "string", + "nullable": true + }, + "priceImpactPct": { + "type": "string", + "nullable": true + }, + "priceImpactUsd": { + "type": "number", + "nullable": true + }, + "timeEstimate": { + "type": "integer", + "nullable": true, + "description": "Estimate в секундах" + }, + "currencyIn": { + "type": "object", + "nullable": true, + "additionalProperties": true + }, + "currencyOut": { + "type": "object", + "nullable": true, + "additionalProperties": true + } + } + } + } } } }, @@ -1312,6 +1506,11 @@ "type": "string", "description": "ULID quote id, полученный от POST /:chain/swap/quote.", "example": "q_01KRKD8GA4XZJ5W4E7VFT2N9M3" + }, + "amountHuman": { + "type": "string", + "description": "Human-readable amount (e.g. \"0.01\"). Server конвертит в smallest units через token decimals. Используется ВМЕСТО поля `amount` — НЕ передавай оба одновременно.", + "example": "0.01" } } }, @@ -1346,6 +1545,11 @@ "normal", "fast" ] + }, + "amountHuman": { + "type": "string", + "description": "Human-readable amount (e.g. \"0.01\"). Server конвертит в smallest units через token decimals. Используется ВМЕСТО поля `amount` — НЕ передавай оба одновременно.", + "example": "0.01" } } }, @@ -1372,6 +1576,11 @@ "minimum": 1, "maximum": 1000, "default": 50 + }, + "amountHuman": { + "type": "string", + "description": "Human-readable amount (e.g. \"0.01\"). Server конвертит в smallest units через token decimals. Используется ВМЕСТО поля `amount` — НЕ передавай оба одновременно.", + "example": "0.01" } } } @@ -1823,6 +2032,11 @@ "EXACT_INPUT", "EXACT_OUTPUT" ] + }, + "amountHuman": { + "type": "string", + "description": "Human-readable amount (e.g. \"0.01\"). Server конвертит в smallest units через token decimals. Используется ВМЕСТО поля `amount` — НЕ передавай оба одновременно.", + "example": "0.01" } } } @@ -2068,6 +2282,11 @@ "fast" ], "description": "BSC only" + }, + "amountHuman": { + "type": "string", + "description": "Human-readable amount (e.g. \"0.01\"). Server конвертит в smallest units через token decimals. Используется ВМЕСТО поля `amount` — НЕ передавай оба одновременно.", + "example": "0.01" } } }, @@ -2098,6 +2317,11 @@ "minimum": 1, "maximum": 1000, "default": 50 + }, + "amountHuman": { + "type": "string", + "description": "Human-readable amount (e.g. \"0.01\"). Server конвертит в smallest units через token decimals. Используется ВМЕСТО поля `amount` — НЕ передавай оба одновременно.", + "example": "0.01" } } } @@ -2140,6 +2364,285 @@ } } } + }, + "/wallets/{chain}/send/cost-estimate": { + "post": { + "summary": "Estimate USD cost of a /send call (read-only, без broadcast)", + "description": "Read-only USD-оценка сколько будет стоить broadcast tx (gas/network fee).\n\nНе дёргает mnemonic, не резервирует idempotency cache, не делает RPC broadcast.\n\nBody — те же поля что у /send МИНУС `to`. Можно прислать `amount` (smallest units, legacy) ИЛИ `amountHuman` (\"0.01\").", + "tags": [ + "Wallet Ops" + ], + "parameters": [ + { + "name": "chain", + "in": "path", + "required": true, + "schema": { + "type": "string", + "enum": [ + "ETH", + "BSC", + "BTC", + "TRX", + "SOL" + ] + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "token": { + "type": "string", + "description": "Token symbol (USDT, USDC, ...). Пусто = native." + }, + "amount": { + "type": "string", + "description": "Smallest units (legacy)" + }, + "amountHuman": { + "type": "string", + "description": "Human-readable amount (e.g. \"0.01\"). Server конвертит в smallest units через token decimals. Используется ВМЕСТО поля `amount` — НЕ передавай оба одновременно.", + "example": "0.01" + }, + "feeTier": { + "type": "string", + "enum": [ + "slow", + "normal", + "fast" + ], + "default": "normal", + "description": "EVM only (ETH/BSC); ignored для TRX/SOL/BTC" + } + } + } + } + } + }, + "responses": { + "200": { + "description": "Cost estimate", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SendCostEstimateResponse" + } + } + } + }, + "400": { + "description": "Validation error" + }, + "502": { + "description": "Gas oracle / price oracle unavailable" + } + } + } + }, + "/wallets/{chain}/swap/cost-estimate": { + "post": { + "summary": "Estimate USD cost of a swap (без cache, без quoteId)", + "description": "Те же поля что у /swap/quote, но возвращает ТОЛЬКО fee + route + approveRequired (без quoteId/expiry/cache).\n\nIdempotent — можно вызывать много раз. Используется для отображения USD-цены свапа в UI ДО того как юзер решит подтвердить.", + "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", + "properties": { + "from": { + "type": "string" + }, + "to": { + "type": "string" + }, + "amount": { + "type": "string", + "description": "Smallest units (legacy)" + }, + "amountHuman": { + "type": "string", + "description": "Human-readable amount (e.g. \"0.01\"). Server конвертит в smallest units через token decimals. Используется ВМЕСТО поля `amount` — НЕ передавай оба одновременно.", + "example": "0.01" + }, + "slippageBps": { + "type": "integer", + "minimum": 1, + "maximum": 1000, + "default": 50 + }, + "feeTier": { + "type": "string", + "enum": [ + "slow", + "normal", + "fast" + ] + } + } + }, + { + "type": "object", + "title": "SOL", + "properties": { + "inputMint": { + "type": "string" + }, + "outputMint": { + "type": "string" + }, + "amount": { + "type": "string", + "description": "Smallest units (legacy)" + }, + "amountHuman": { + "type": "string", + "description": "Human-readable amount (e.g. \"0.01\"). Server конвертит в smallest units через token decimals. Используется ВМЕСТО поля `amount` — НЕ передавай оба одновременно.", + "example": "0.01" + }, + "slippageBps": { + "type": "integer", + "minimum": 1, + "maximum": 1000, + "default": 50 + } + } + } + ] + } + } + } + }, + "responses": { + "200": { + "description": "Swap cost estimate", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SwapCostEstimateResponse" + } + } + } + }, + "400": { + "description": "Validation error" + }, + "404": { + "description": "Wallet not found" + }, + "502": { + "description": "Upstream RPC / quote failed" + } + } + } + }, + "/relay/cost-estimate": { + "post": { + "summary": "Estimate USD cost of a bridge (Relay quote — trimmed, без steps[])", + "description": "Вызывает Relay /quote внутри и фильтрует response — отдаёт только fees + details (rate, time, impact, currencyIn/Out).\n\nБез `steps[]` (которые тяжёлые и содержат unsigned txs). Поведение JWT-binding (body.user, body.recipient) — то же что у /relay/quote.", + "tags": [ + "Bridge (Relay)" + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "user": { + "type": "string", + "description": "Sender address (должен совпадать с user's wallet origin chain)" + }, + "recipient": { + "type": "string" + }, + "originChainId": { + "type": "integer", + "description": "1=ETH, 56=BSC, 792703809=SOL" + }, + "destinationChainId": { + "type": "integer" + }, + "originCurrency": { + "type": "string", + "description": "Contract address (для EVM) или mint (для SOL)" + }, + "destinationCurrency": { + "type": "string" + }, + "amount": { + "type": "string", + "description": "Smallest units (legacy)" + }, + "amountHuman": { + "type": "string", + "description": "Human-readable amount (e.g. \"0.01\"). Server конвертит в smallest units через token decimals. Используется ВМЕСТО поля `amount` — НЕ передавай оба одновременно.", + "example": "0.01" + }, + "tradeType": { + "type": "string", + "enum": [ + "EXACT_INPUT", + "EXACT_OUTPUT" + ] + } + } + } + } + } + }, + "responses": { + "200": { + "description": "Bridge cost estimate", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BridgeCostEstimateResponse" + } + } + } + }, + "400": { + "description": "Validation error / unknown originCurrency для amountHuman" + }, + "403": { + "description": "body.user/recipient не совпадает с user wallets" + }, + "502": { + "description": "Relay upstream error" + }, + "504": { + "description": "Relay timeout" + } + } + } } } }