diff --git a/apps/api/src/controllers/wallet.controller.ts b/apps/api/src/controllers/wallet.controller.ts index 26e47e9..27dec45 100644 --- a/apps/api/src/controllers/wallet.controller.ts +++ b/apps/api/src/controllers/wallet.controller.ts @@ -1057,12 +1057,18 @@ export const WalletController = { amount: raw.networkFee.amount, amountFormatted: fmtUnits(raw.networkFee.amount, networkFeeAssetDecimals), amountUsd: networkFeeUsd, + // Сетевая комиссия в эквиваленте валюты свапа (входного токена) + символ. + amountInInputToken: (networkFeeUsd != null && fromPriceUsd != null && fromPriceUsd > 0) + ? trimNum(networkFeeUsd / fromPriceUsd) : null, + inputTokenSymbol: fromSymbol, }, // App fee 0.7% (BSC only) — сервер шлёт это через off-chain double-tx ПЕРЕД swap. app: raw.appFee ? { asset: raw.appFee.asset, amount: raw.appFee.amount, amountFormatted: fmtUnits(raw.appFee.amount, raw.fromDecimals), + // USDT-эквивалент 0.7% (комиссия уже в токене через amountFormatted). + amountUsd: toUsd(raw.appFee.amount, raw.fromDecimals, fromPriceUsd), recipient: raw.appFee.recipient, } : null, // dex fee включён в expectedOut (Pancake 0.25%, SunSwap 0.3%+0.7% fee router, Jupiter platform varied). @@ -1423,26 +1429,34 @@ export const WalletController = { feeAssetSymbolForPrice = 'SOL'; } - // USD enrichment fee asset + // USD enrichment: сетевой fee-asset + входной токен (для эквивалента в валюте свапа + USDT нашей 0.7%) + const inputSymbol = raw.appFee?.asset ?? null; // символ входного токена (= asset нашей 0.7%) const priceMap = await getPricesBySymbols([ { chain, symbol: feeAssetSymbolForPrice }, + ...(inputSymbol ? [{ chain, symbol: inputSymbol }] : []), ]).catch(() => new Map()); const feePriceUsd = priceMap.get(`${chain}:${feeAssetSymbolForPrice}`) ?? null; + const inputPriceUsd = inputSymbol ? (priceMap.get(`${chain}:${inputSymbol}`) ?? null) : null; + + const usdFromRaw = (rawAmt: string, dec: number, price: number | null): number | null => { + if (price == null) return null; + try { + const big = BigInt(rawAmt); + const divisor = 10n ** BigInt(dec); + return (Number(big / divisor) + Number(big % divisor) / Number(divisor)) * price; + } catch { return 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; } - } + const amountUsd = usdFromRaw(raw.networkFee.amount, feeAssetDecimals, feePriceUsd); + // Сетевая комиссия в эквиваленте входного токена (валюты свапа). + const networkInInputToken = (amountUsd != null && inputPriceUsd != null && inputPriceUsd > 0) + ? trimNum(amountUsd / inputPriceUsd) : null; + // Наша 0.7% в USDT (в токене — formatSmallestUnits ниже). + const appFeeUsd = raw.appFee ? usdFromRaw(raw.appFee.amount, raw.fromDecimals, inputPriceUsd) : null; res.json({ success: true, @@ -1453,7 +1467,15 @@ export const WalletController = { amount: raw.networkFee.amount, amountFormatted, amountUsd, + amountInInputToken: networkInInputToken, + inputTokenSymbol: inputSymbol, }, + appFee: raw.appFee ? { + asset: raw.appFee.asset, + amount: raw.appFee.amount, + amountFormatted: formatSmallestUnits(raw.appFee.amount, raw.fromDecimals), + amountUsd: appFeeUsd, + } : null, total: { amountUsd }, route: raw.route, approveRequired: raw.approveRequired, diff --git a/apps/api/src/lib/token-registry.ts b/apps/api/src/lib/token-registry.ts index 7ab8acb..4db12d4 100644 --- a/apps/api/src/lib/token-registry.ts +++ b/apps/api/src/lib/token-registry.ts @@ -293,8 +293,10 @@ export function getCoingeckoId(chain: ChainCode, symbol: string): string | null const upper = String(symbol || '').toUpperCase(); if (!upper) return null; - // Native — symbol === chain code (BTC, ETH, ...). - if (upper === chain) return NATIVE_COINGECKO_IDS[chain] ?? null; + // Native — symbol === chain code (BTC/ETH/SOL/TRX) ИЛИ символ нативной монеты. + // На BSC нативка = BNB (символ ≠ chain code 'BSC') — без этой ветки цена BNB не резолвилась + // (→ сетевая/0.7% комиссия в BNB приходила без USD). NATIVE_SYMBOLS[BSC]='BNB'. + if (upper === chain || upper === NATIVE_SYMBOLS[chain]) return NATIVE_COINGECKO_IDS[chain] ?? null; if (chain === 'ETH' || chain === 'BSC') { const t = getEvmTokens(chain).find((x) => x.symbol.toUpperCase() === upper); diff --git a/apps/api/src/services/sol-stake.service.ts b/apps/api/src/services/sol-stake.service.ts index d579a0e..12f65d2 100644 --- a/apps/api/src/services/sol-stake.service.ts +++ b/apps/api/src/services/sol-stake.service.ts @@ -22,6 +22,7 @@ import { } from '@solana/web3.js'; import { getAppFeeWallet, computeAppFee } from '../lib/app-fee'; import { formatSmallestUnits } from '../lib/amount-units'; +import { getPricesBySymbols } from './price-oracle.service'; import { signAndBroadcastSolBuilt } from './wallet-signer.service'; import { StakingError } from './staking.service'; import { resolveStakeValidator } from '../lib/sol-validator'; @@ -47,6 +48,7 @@ export interface SolStakeQuote { amountLamportsHuman: string; appFeeLamports: string; appFeeLamportsHuman: string; + appFeeUsd: number | null; // наша 0.7% в USDT (≈USD); null если цена SOL недоступна stakeLamports: string; stakeLamportsHuman: string; validator: string; @@ -62,11 +64,17 @@ export async function quoteSolStake(amountLamports: string): Promise { const stakeAmount = BigInt(amountWei) - fee; if (stakeAmount <= 0n) throw badRequest('amount too small after 0.7% fee'); const apr = await fetchLidoApr(); + // USDT-эквивалент комиссии (≈USD). Цена ETH через whitelist; null если недоступна. + let ethPrice: number | null = null; + try { ethPrice = (await getPricesBySymbols([{ chain: 'ETH', symbol: 'ETH' }])).get('ETH:ETH') ?? null; } catch { /* optional */ } + const appFeeHuman = eth(fee.toString()); + const appFeeUsd = ethPrice != null ? Math.round(Number(appFeeHuman) * ethPrice * 1e6) / 1e6 : null; return { amountWei, amountWeiHuman: eth(amountWei), appFeeWei: fee.toString(), - appFeeWeiHuman: eth(fee.toString()), + appFeeWeiHuman: appFeeHuman, + appFeeUsd, stakeAmountWei: stakeAmount.toString(), stakeAmountWeiHuman: eth(stakeAmount.toString()), // Lido минтит stETH ~1:1 к внесённому ETH (после fee) diff --git a/apps/api/src/services/uniswap-lp.service.ts b/apps/api/src/services/uniswap-lp.service.ts index c0a3d78..11897c0 100644 --- a/apps/api/src/services/uniswap-lp.service.ts +++ b/apps/api/src/services/uniswap-lp.service.ts @@ -56,6 +56,12 @@ function badRequest(msg: string): StakingError { const MINT_GAS_LIMIT = '600000'; const REMOVE_GAS_LIMIT = '500000'; +// Оценочные gas-лимиты шагов LP-депозита (для networkFee в quote). Соответствуют реальным: +// mint=MINT_GAS_LIMIT, approve≈APPROVE_GAS_LIMIT, fee≈ERC20 transfer, wrap≈WETH.deposit. +const EST_GAS_MINT = 600_000n; +const EST_GAS_APPROVE = 80_000n; +const EST_GAS_FEE = 65_000n; +const EST_GAS_WRAP = 60_000n; const DEFAULT_SLIPPAGE_BPS = 100n; // 1% const MAX_SLIPPAGE_BPS = 500n; // 5% — верхняя граница (дефолт 1%) const SLIPPAGE_DENOM = 10000n; @@ -220,6 +226,13 @@ export interface AddQuote { appFee1Usd: number | null; appFeeTotalUsd: number | null; appFeeTotalEth: string | null; // комиссия в монете сети (ETH-эквивалент) + // ── Оценка сетевой (gas) комиссии всего LP-депозита (wrap?+fee×ноги+approve×ноги+mint) ── + networkFee: { + estGasUnits: string; + gasPriceGwei: string | null; + amountEthHuman: string | null; // оценка газа в ETH + amountUsd: number | null; // та же оценка в USD + }; // ── Проверка баланса (только если передан ownerAddress) ── balance?: { token0: LegBalance; @@ -328,6 +341,25 @@ export async function quoteAdd(p: AddParams): Promise { price1 = isWeth(pool.token1) ? ethPrice : (priceMap.get(`ETH:${pool.sym1}`) ?? null); } catch { /* prices optional */ } + // ── Оценка сетевой (gas) комиссии: суммируем gas-лимиты шагов, которые реально пойдут ── + const t0IsWethN = isWeth(pool.token0); + const t1IsWethN = isWeth(pool.token1); + let estGas = EST_GAS_MINT; // mint всегда + if (in0 > 0n) estGas += EST_GAS_APPROVE + EST_GAS_FEE; // нога token0: approve + fee + if (in1 > 0n) estGas += EST_GAS_APPROVE + EST_GAS_FEE; // нога token1 + if (p.useNativeEth && (t0IsWethN || t1IsWethN)) estGas += EST_GAS_WRAP; // авто-wrap ETH→WETH + let gasPriceWei = 0n; + try { + const fd = await provider.getFeeData(); + const gp = fd.maxFeePerGas ?? fd.gasPrice ?? null; + if (gp) gasPriceWei = BigInt(gp.toString()); + } catch { /* gas price optional */ } + const networkFeeWei = estGas * gasPriceWei; + const networkFeeEthHuman = gasPriceWei > 0n ? formatSmallestUnits(networkFeeWei.toString(), 18) : null; + const networkFeeUsd = (networkFeeEthHuman != null && ethPrice != null) + ? roundUsd(Number(networkFeeEthHuman) * ethPrice) : null; + const gasPriceGwei = gasPriceWei > 0n ? formatSmallestUnits(gasPriceWei.toString(), 9) : null; + const fee0Usd = price0 != null ? roundUsd(Number(fee0Human) * price0) : null; const fee1Usd = price1 != null ? roundUsd(Number(fee1Human) * price1) : null; const usdParts = [fee0Usd, fee1Usd].filter((v): v is number => v != null); @@ -405,6 +437,12 @@ export async function quoteAdd(p: AddParams): Promise { appFee1Usd: fee1Usd, appFeeTotalUsd, appFeeTotalEth, + networkFee: { + estGasUnits: estGas.toString(), + gasPriceGwei, + amountEthHuman: networkFeeEthHuman, + amountUsd: networkFeeUsd, + }, ...(balance ? { balance } : {}), }; } diff --git a/apps/api/swagger.json b/apps/api/swagger.json index 7c797b9..006ee02 100644 --- a/apps/api/swagger.json +++ b/apps/api/swagger.json @@ -670,56 +670,30 @@ "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 - } + "asset": { "type": "string", "example": "BNB" }, + "amount": { "type": "string", "example": "65000000000000" }, + "amountFormatted": { "type": "string", "example": "0.000065", "description": "Сетевая комиссия в native-монете" }, + "amountUsd": { "type": "number", "nullable": true, "example": 0.04 }, + "amountInInputToken": { "type": "string", "nullable": true, "example": "0.04", "description": "Та же сетевая комиссия в эквиваленте валюты свапа (входного токена)" }, + "inputTokenSymbol": { "type": "string", "example": "USDT", "description": "Символ входного токена (валюты свапа)" } } }, "total": { "type": "object", "properties": { - "amountUsd": { - "type": "number", - "nullable": true, - "example": 0.04 - } + "amountUsd": { "type": "number", "nullable": true, "example": 0.04 } } }, "app": { "type": "object", "nullable": true, - "description": "App fee 0.7% (BSC only). Server sends this to BSC_FEE_WALLET via separate tx BEFORE swap.", + "description": "Наша комиссия 0.7%. Списывается отдельной tx ПЕРЕД свапом.", "properties": { - "asset": { - "type": "string", - "example": "BNB" - }, - "amount": { - "type": "string", - "example": "70000000000000" - }, - "amountFormatted": { - "type": "string", - "example": "0.00007" - }, - "recipient": { - "type": "string", - "example": "0xeDEb157eF86A4ecd1242762f339c2Bd5a0822718" - } + "asset": { "type": "string", "example": "USDT", "description": "Токен комиссии (= входной токен)" }, + "amount": { "type": "string", "example": "70000000000000" }, + "amountFormatted": { "type": "string", "example": "0.07", "description": "Комиссия 0.7% в токене" }, + "amountUsd": { "type": "number", "nullable": true, "example": 0.07, "description": "Та же 0.7% в USDT-эквиваленте" }, + "recipient": { "type": "string", "example": "0xeb9fbf0d137ef5ea7b9959044c2ed44ec1206c68" } } } } @@ -818,21 +792,25 @@ }, "fee": { "type": "object", + "description": "Сетевая (gas) комиссия", "properties": { - "asset": { - "type": "string", - "example": "BNB" - }, - "amount": { - "type": "string" - }, - "amountFormatted": { - "type": "string" - }, - "amountUsd": { - "type": "number", - "nullable": true - } + "asset": { "type": "string", "example": "BNB" }, + "amount": { "type": "string" }, + "amountFormatted": { "type": "string", "description": "в native-монете" }, + "amountUsd": { "type": "number", "nullable": true }, + "amountInInputToken": { "type": "string", "nullable": true, "description": "та же сетевая комиссия в эквиваленте валюты свапа (входного токена)" }, + "inputTokenSymbol": { "type": "string", "nullable": true, "example": "USDT" } + } + }, + "appFee": { + "type": "object", + "nullable": true, + "description": "Наша комиссия 0.7% в токене + USDT", + "properties": { + "asset": { "type": "string", "example": "USDT" }, + "amount": { "type": "string" }, + "amountFormatted": { "type": "string", "description": "0.7% в токене" }, + "amountUsd": { "type": "number", "nullable": true, "description": "та же 0.7% в USDT" } } }, "total": { @@ -1154,7 +1132,13 @@ "type": "string" }, "appFeeWeiHuman": { - "type": "string" + "type": "string", + "description": "наша 0.7% в токене (ETH). SOL-ответ: appFeeLamportsHuman" + }, + "appFeeUsd": { + "type": "number", + "nullable": true, + "description": "наша 0.7% в USDT (≈USD). Есть и в SOL-ответе (appFeeUsd). null если цена недоступна" }, "stakeAmountWei": { "type": "string" @@ -1463,7 +1447,17 @@ "appFee0Usd": { "type": "number", "nullable": true }, "appFee1Usd": { "type": "number", "nullable": true }, "appFeeTotalUsd": { "type": "number", "nullable": true }, - "appFeeTotalEth": { "type": "string", "nullable": true, "description": "суммарная комиссия в монете сети (ETH)" }, + "appFeeTotalEth": { "type": "string", "nullable": true, "description": "суммарная наша 0.7% в монете сети (ETH)" }, + "networkFee": { + "type": "object", + "description": "Оценка сетевой (gas) комиссии всего LP-депозита (wrap?+fee×ноги+approve×ноги+mint) при текущем gasPrice", + "properties": { + "estGasUnits": { "type": "string", "example": "950000" }, + "gasPriceGwei": { "type": "string", "nullable": true, "example": "5.2" }, + "amountEthHuman": { "type": "string", "nullable": true, "example": "0.00494", "description": "оценка газа в ETH" }, + "amountUsd": { "type": "number", "nullable": true, "example": 12.3, "description": "та же оценка в USD" } + } + }, "balance": { "type": "object", "description": "есть только если у юзера есть ETH-кошелёк",