diff --git a/src/widgets/staking/ui/StakeCard.tsx b/src/widgets/staking/ui/StakeCard.tsx index fd6064d..b71e203 100644 --- a/src/widgets/staking/ui/StakeCard.tsx +++ b/src/widgets/staking/ui/StakeCard.tsx @@ -1,4 +1,3 @@ -import { useEffect, useState } from 'react' import { PrimaryButton, TokenIcon } from '@shared/ui' import { COIN_ICONS } from '@shared/assets/coins' import { truncateDecimals } from '@shared/lib/utils/truncateDecimals' @@ -14,7 +13,7 @@ interface Props { chain: StakeChain amount: string onAmountChange: (value: string) => void - onStake: () => void + onStake: (stakeAmountHuman: string) => void isStaking: boolean } @@ -28,39 +27,46 @@ function usd(value: number): string { return `≈ $${value.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}` } +// Сумма в поле ввода (T) — это сумма к списанию с кошелька. Из неё резервируется газ (G), +// в стейкинг уходит A = T − G, и уже на A сервер считает комиссию 0.7% и итоговый stETH. function buildRows( meta: Meta, quote: StakeQuote | undefined, opCost: OpCost | undefined, - amountNum: number, + totalNum: number, nativeUsdPrice: number ): QuoteRow[] { - const resultLabel = meta.symbol === 'ETH' ? 'Получите stETH' : 'Делегируется валидатору' + const isEth = meta.symbol === 'ETH' const aprLabel = `Годовая доходность (${meta.aprLabel})` const fee = quote ? `${truncateDecimals(quote.feeHuman, 8)} ${meta.symbol}` : DASH - const afterFee = quote ? `${truncateDecimals(quote.afterFeeHuman, 8)} ${meta.symbol}` : DASH - const result = quote - ? meta.symbol === 'ETH' - ? `≈ ${truncateDecimals(quote.resultHuman, 8)}` - : `${truncateDecimals(quote.resultHuman, 8)} SOL` - : DASH const apr = quote?.aprPercent != null ? `${quote.aprPercent}%` : DASH - // Сетевой сбор (газ) и итог к списанию в USD — из op-cost. + const resultLabel = isEth ? 'Вы получите' : 'Делегируется валидатору' + const result = quote + ? isEth + ? `≈ ${truncateDecimals(quote.resultHuman, 8)} stETH` + : `${truncateDecimals(quote.resultHuman, 8)} SOL` + : DASH + const gasRaw = opCost?.balance?.gas?.needReserve const gasNum = gasRaw != null ? parseFloat(String(gasRaw)) || 0 : 0 const gas = gasRaw != null ? `≈ ${truncateDecimals(String(gasRaw), 8)} ${meta.symbol}` : DASH - // Из кошелька списывается сумма (комиссия 0.7% внутри неё) + газ нативкой. - const totalUsd = opCost && nativeUsdPrice > 0 && amountNum > 0 ? usd((amountNum + gasNum) * nativeUsdPrice) : DASH + + // Сумма, реально уходящая в стейкинг (до комиссии сервиса): T − газ. + const stakeNum = Math.max(0, totalNum - gasNum) + const stakeAmount = gasRaw != null && totalNum > 0 ? `≈ ${truncateDecimals(stakeNum.toString(), 8)} ${meta.symbol}` : DASH + + // Газ уже внутри суммы к списанию, поэтому итог = T в USD. + const totalUsd = nativeUsdPrice > 0 && totalNum > 0 ? usd(totalNum * nativeUsdPrice) : DASH return [ - { label: 'Комиссия сервиса (0.7%)', value: fee }, { label: 'Сетевой сбор (газ)', value: gas }, - { label: 'Сумма после комиссии', value: afterFee }, - { label: resultLabel, value: result, accent: 'highlight' }, - { label: 'Итого к списанию', value: totalUsd }, + { label: 'Сумма в стейкинг', value: stakeAmount }, + { label: 'Комиссия сервиса (0.7%)', value: fee }, { label: aprLabel, value: apr, accent: 'success' }, + { label: 'Итого к списанию', value: totalUsd }, + { label: resultLabel, value: result, accent: 'highlight', emphasis: true }, ] } @@ -72,65 +78,48 @@ export function StakeCard({ chain, amount, onAmountChange, onStake, isStaking }: const balanceNum = parseFloat(balanceHuman) || 0 const debounced = useDebounce(amount, 400) - const { data: quote, isFetching } = useStakeQuote(chain, debounced) const { data: opCost, isError: opError } = useOpCost(chain, debounced) - // Газ за операцию резервируется в том же нативном токене, что и сумма стейка - // (op-cost → balance.gas.needReserve). Значит максимум к стейку = баланс − этот резерв. - // Считаем в смолл-юнитах через BigInt, чтобы не ловить ошибки float-вычитания. + // Газ за операцию резервируется в том же нативном токене (op-cost → balance.gas.needReserve). const gasReserveHuman = opCost?.balance?.gas?.needReserve != null ? String(opCost.balance.gas.needReserve) : null - const stakeableHuman = (() => { - if (gasReserveHuman == null) return balanceHuman - const balBase = BigInt(toBaseUnits(balanceHuman, decimals)) - const reserveBase = BigInt(toBaseUnits(gasReserveHuman, decimals)) - const maxBase = balBase > reserveBase ? balBase - reserveBase : 0n - return fromBaseUnits(maxBase.toString(), decimals) - })() - // Клик по «МАКС/100%» до того, как известен резерв на газ: ставим полный баланс, чтобы - // дёрнуть op-cost, и уменьшаем сумму на резерв, как только он приходит (или сдаёмся при ошибке). - const [maxPending, setMaxPending] = useState(false) - useEffect(() => { - if (!maxPending) return + // Сумма к стейку A = T − газ. Считаем в смолл-юнитах через BigInt (без float-погрешностей). + // Газ неизвестен: при ошибке op-cost отдаём всю сумму (бэкенд провалидирует), пока грузится — 0. + function computeStakeAmount(totalHuman: string): string { + const totalNum = parseFloat(totalHuman) || 0 + if (totalNum <= 0) return '0' if (gasReserveHuman != null) { - onAmountChange(stakeableHuman) - setMaxPending(false) - } else if (opError) { - setMaxPending(false) + const totalBase = BigInt(toBaseUnits(totalHuman, decimals)) + const gasBase = BigInt(toBaseUnits(gasReserveHuman, decimals)) + const stakeBase = totalBase > gasBase ? totalBase - gasBase : 0n + return fromBaseUnits(stakeBase.toString(), decimals) } - }, [maxPending, gasReserveHuman, stakeableHuman, opError, onAmountChange]) + return opError ? totalHuman : '0' + } + + const stakeAmountHuman = computeStakeAmount(amount) + const stakeAmountDebounced = computeStakeAmount(debounced) + const stakeAmountNum = parseFloat(stakeAmountHuman) || 0 + + // Котировку (комиссия, итог, stETH) считаем на сумму стейка A, а не на сумму к списанию T. + const { data: quote, isFetching } = useStakeQuote(chain, stakeAmountDebounced) const amountNum = parseFloat(amount) || 0 const withinBalance = amountNum > 0 && amountNum <= balanceNum - // Достаточность с учётом газа проверяет сервер (op-cost). Если эндпоинт недоступен — - // не блокируем жёстко, откатываемся к проверке «не больше баланса». - const sufficient = opError ? withinBalance : opCost?.sufficient === true - const insufficient = withinBalance && opCost?.sufficient === false && !maxPending - const gasShort = insufficient && opCost?.balance?.gas?.sufficient === false - const canStake = withinBalance && sufficient && !isStaking + // Сумма к списанию меньше сетевого сбора — стейкать нечего. + const belowGas = withinBalance && gasReserveHuman != null && stakeAmountNum <= 0 + const canStake = withinBalance && stakeAmountNum > 0 && !isStaking const setPercent = (p: number) => { - if (p >= 100) { - // Максимум: либо сразу вычитаем известный резерв, либо ставим полный баланс и ждём op-cost. - if (gasReserveHuman != null) onAmountChange(stakeableHuman) - else { - setMaxPending(true) - onAmountChange(balanceHuman) - } - return - } - onAmountChange(((balanceNum * p) / 100).toString()) + // МАКС/100% = весь баланс; поле НЕ пересчитывается на газ — газ вычитается ниже, внутри A. + if (p >= 100) onAmountChange(balanceHuman) + else onAmountChange(((balanceNum * p) / 100).toString()) } const pills = ( <> {PERCENTS.map((p) => ( - ))} @@ -143,7 +132,7 @@ export function StakeCard({ chain, amount, onAmountChange, onStake, isStaking }:
- Сумма стейка + Сумма к списанию
{meta.aprLabel} {quote?.aprPercent != null ? `${quote.aprPercent}%` : '—'} @@ -163,7 +152,7 @@ export function StakeCard({ chain, amount, onAmountChange, onStake, isStaking }: if (/^(\d+\.?\d*|\.?\d*)$/.test(v) || v === '') onAmountChange(v) }} placeholder="0" - aria-label={`Сумма стейка в ${meta.symbol}`} + aria-label={`Сумма к списанию в ${meta.symbol}`} />
@@ -174,11 +163,7 @@ export function StakeCard({ chain, amount, onAmountChange, onStake, isStaking }:
Баланс: {truncateDecimals(balanceHuman, 6)} {meta.symbol} - @@ -189,19 +174,13 @@ export function StakeCard({ chain, amount, onAmountChange, onStake, isStaking }: loading={isFetching} /> - {insufficient && ( -

- {gasShort - ? `Недостаточно ${meta.symbol} на сетевую комиссию (газ).` - : 'Недостаточно средств для стейкинга с учётом комиссии и газа.'} -

- )} + {belowGas &&

Сумма меньше сетевого сбора (газ).

}
onStake(stakeAmountHuman)} disabled={!canStake} />
diff --git a/src/widgets/staking/ui/StakeQuotePanel.module.css b/src/widgets/staking/ui/StakeQuotePanel.module.css index 3ec4c6a..5f4522e 100644 --- a/src/widgets/staking/ui/StakeQuotePanel.module.css +++ b/src/widgets/staking/ui/StakeQuotePanel.module.css @@ -43,3 +43,17 @@ .highlight { color: var(--highlight); } + +/* Финальный акцентный итог «Вы получите …» — отделён разделителем и крупнее. */ +.rowTotal { + margin-top: 4px; + padding-top: 16px; + border-top: 1px solid rgba(255, 255, 255, 0.12); + border-bottom: none; + align-items: baseline; +} + +.valueLg { + font-size: 24px; + font-weight: 700; +} diff --git a/src/widgets/staking/ui/StakeQuotePanel.tsx b/src/widgets/staking/ui/StakeQuotePanel.tsx index 12be407..586d1d3 100644 --- a/src/widgets/staking/ui/StakeQuotePanel.tsx +++ b/src/widgets/staking/ui/StakeQuotePanel.tsx @@ -4,6 +4,8 @@ export interface QuoteRow { label: string value: string accent?: 'success' | 'highlight' + /** Финальный акцентный итог — отделяется разделителем и показывается крупнее. */ + emphasis?: boolean } interface Props { @@ -14,10 +16,12 @@ interface Props { export function StakeQuotePanel({ rows, loading }: Props) { return (
- {rows.map(({ label, value, accent }) => ( -
+ {rows.map(({ label, value, accent, emphasis }) => ( +
{label} - {value} + + {value} +
))}
diff --git a/src/widgets/staking/ui/StakingWidget.tsx b/src/widgets/staking/ui/StakingWidget.tsx index 80cc117..6d4fb3e 100644 --- a/src/widgets/staking/ui/StakingWidget.tsx +++ b/src/widgets/staking/ui/StakingWidget.tsx @@ -1,5 +1,6 @@ import { useState } from 'react' import { Notification } from '@shared/ui' +import { truncateDecimals } from '@shared/lib/utils/truncateDecimals' import { ChainToggle } from './ChainToggle' import { StakeCard } from './StakeCard' import { PositionsPanel } from './PositionsPanel' @@ -34,12 +35,16 @@ export function StakingWidget() { setAmount('') } - function handleStake() { + // stakeAmountHuman — это сумма стейка A (= введённое − газ), её и отправляем на бэкенд. + function handleStake(stakeAmountHuman: string) { stakeM.mutate( - { chain, amountHuman: amount }, + { chain, amountHuman: stakeAmountHuman }, { onSuccess: () => { - setToast({ status: 'success', message: `Стейкинг ${amount} ${CHAIN_META[chain].symbol} выполнен` }) + setToast({ + status: 'success', + message: `Стейкинг ${truncateDecimals(stakeAmountHuman, 6)} ${CHAIN_META[chain].symbol} выполнен`, + }) setAmount('') }, onError: (e) => setToast({ status: 'error', message: getStakingErrorMessage(e) }),