add staking page

This commit is contained in:
2026-06-16 17:17:22 +03:00
parent ff1726ab02
commit 5a6440de21
4 changed files with 83 additions and 81 deletions

View File

@@ -1,4 +1,3 @@
import { useEffect, useState } from 'react'
import { PrimaryButton, TokenIcon } from '@shared/ui' import { PrimaryButton, TokenIcon } from '@shared/ui'
import { COIN_ICONS } from '@shared/assets/coins' import { COIN_ICONS } from '@shared/assets/coins'
import { truncateDecimals } from '@shared/lib/utils/truncateDecimals' import { truncateDecimals } from '@shared/lib/utils/truncateDecimals'
@@ -14,7 +13,7 @@ interface Props {
chain: StakeChain chain: StakeChain
amount: string amount: string
onAmountChange: (value: string) => void onAmountChange: (value: string) => void
onStake: () => void onStake: (stakeAmountHuman: string) => void
isStaking: boolean isStaking: boolean
} }
@@ -28,39 +27,46 @@ function usd(value: number): string {
return `$${value.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}` return `$${value.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`
} }
// Сумма в поле ввода (T) — это сумма к списанию с кошелька. Из неё резервируется газ (G),
// в стейкинг уходит A = T G, и уже на A сервер считает комиссию 0.7% и итоговый stETH.
function buildRows( function buildRows(
meta: Meta, meta: Meta,
quote: StakeQuote | undefined, quote: StakeQuote | undefined,
opCost: OpCost | undefined, opCost: OpCost | undefined,
amountNum: number, totalNum: number,
nativeUsdPrice: number nativeUsdPrice: number
): QuoteRow[] { ): QuoteRow[] {
const resultLabel = meta.symbol === 'ETH' ? 'Получите stETH' : 'Делегируется валидатору' const isEth = meta.symbol === 'ETH'
const aprLabel = `Годовая доходность (${meta.aprLabel})` const aprLabel = `Годовая доходность (${meta.aprLabel})`
const fee = quote ? `${truncateDecimals(quote.feeHuman, 8)} ${meta.symbol}` : DASH 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 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 gasRaw = opCost?.balance?.gas?.needReserve
const gasNum = gasRaw != null ? parseFloat(String(gasRaw)) || 0 : 0 const gasNum = gasRaw != null ? parseFloat(String(gasRaw)) || 0 : 0
const gas = gasRaw != null ? `${truncateDecimals(String(gasRaw), 8)} ${meta.symbol}` : DASH 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 [ return [
{ label: 'Комиссия сервиса (0.7%)', value: fee },
{ label: 'Сетевой сбор (газ)', value: gas }, { label: 'Сетевой сбор (газ)', value: gas },
{ label: 'Сумма после комиссии', value: afterFee }, { label: 'Сумма в стейкинг', value: stakeAmount },
{ label: resultLabel, value: result, accent: 'highlight' }, { label: 'Комиссия сервиса (0.7%)', value: fee },
{ label: 'Итого к списанию', value: totalUsd },
{ label: aprLabel, value: apr, accent: 'success' }, { 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 balanceNum = parseFloat(balanceHuman) || 0
const debounced = useDebounce(amount, 400) const debounced = useDebounce(amount, 400)
const { data: quote, isFetching } = useStakeQuote(chain, debounced)
const { data: opCost, isError: opError } = useOpCost(chain, debounced) const { data: opCost, isError: opError } = useOpCost(chain, debounced)
// Газ за операцию резервируется в том же нативном токене, что и сумма стейка // Газ за операцию резервируется в том же нативном токене (op-cost → balance.gas.needReserve).
// (op-cost → balance.gas.needReserve). Значит максимум к стейку = баланс этот резерв.
// Считаем в смолл-юнитах через BigInt, чтобы не ловить ошибки float-вычитания.
const gasReserveHuman = opCost?.balance?.gas?.needReserve != null ? String(opCost.balance.gas.needReserve) : null 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%» до того, как известен резерв на газ: ставим полный баланс, чтобы // Сумма к стейку A = T газ. Считаем в смолл-юнитах через BigInt (без float-погрешностей).
// дёрнуть op-cost, и уменьшаем сумму на резерв, как только он приходит (или сдаёмся при ошибке). // Газ неизвестен: при ошибке op-cost отдаём всю сумму (бэкенд провалидирует), пока грузится — 0.
const [maxPending, setMaxPending] = useState(false) function computeStakeAmount(totalHuman: string): string {
useEffect(() => { const totalNum = parseFloat(totalHuman) || 0
if (!maxPending) return if (totalNum <= 0) return '0'
if (gasReserveHuman != null) { if (gasReserveHuman != null) {
onAmountChange(stakeableHuman) const totalBase = BigInt(toBaseUnits(totalHuman, decimals))
setMaxPending(false) const gasBase = BigInt(toBaseUnits(gasReserveHuman, decimals))
} else if (opError) { const stakeBase = totalBase > gasBase ? totalBase - gasBase : 0n
setMaxPending(false) 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 amountNum = parseFloat(amount) || 0
const withinBalance = amountNum > 0 && amountNum <= balanceNum const withinBalance = amountNum > 0 && amountNum <= balanceNum
// Достаточность с учётом газа проверяет сервер (op-cost). Если эндпоинт недоступен — // Сумма к списанию меньше сетевого сбора — стейкать нечего.
// не блокируем жёстко, откатываемся к проверке «не больше баланса». const belowGas = withinBalance && gasReserveHuman != null && stakeAmountNum <= 0
const sufficient = opError ? withinBalance : opCost?.sufficient === true const canStake = withinBalance && stakeAmountNum > 0 && !isStaking
const insufficient = withinBalance && opCost?.sufficient === false && !maxPending
const gasShort = insufficient && opCost?.balance?.gas?.sufficient === false
const canStake = withinBalance && sufficient && !isStaking
const setPercent = (p: number) => { const setPercent = (p: number) => {
if (p >= 100) { // МАКС/100% = весь баланс; поле НЕ пересчитывается на газ — газ вычитается ниже, внутри A.
// Максимум: либо сразу вычитаем известный резерв, либо ставим полный баланс и ждём op-cost. if (p >= 100) onAmountChange(balanceHuman)
if (gasReserveHuman != null) onAmountChange(stakeableHuman) else onAmountChange(((balanceNum * p) / 100).toString())
else {
setMaxPending(true)
onAmountChange(balanceHuman)
}
return
}
onAmountChange(((balanceNum * p) / 100).toString())
} }
const pills = ( const pills = (
<> <>
{PERCENTS.map((p) => ( {PERCENTS.map((p) => (
<button <button key={p} className={styles.pill} onClick={() => setPercent(p)} aria-label={`${p}% от баланса`}>
key={p}
className={styles.pill}
onClick={() => setPercent(p)}
aria-label={`${p}% от баланса`}
>
{p}% {p}%
</button> </button>
))} ))}
@@ -143,7 +132,7 @@ export function StakeCard({ chain, amount, onAmountChange, onStake, isStaking }:
<div className={styles.card}> <div className={styles.card}>
<div className={styles.top}> <div className={styles.top}>
<span className={styles.tag}>Сумма стейка</span> <span className={styles.tag}>Сумма к списанию</span>
<div className={styles.right}> <div className={styles.right}>
<span className={styles.apr}> <span className={styles.apr}>
{meta.aprLabel} {quote?.aprPercent != null ? `${quote.aprPercent}%` : '—'} {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) if (/^(\d+\.?\d*|\.?\d*)$/.test(v) || v === '') onAmountChange(v)
}} }}
placeholder="0" placeholder="0"
aria-label={`Сумма стейка в ${meta.symbol}`} aria-label={`Сумма к списанию в ${meta.symbol}`}
/> />
<div className={styles.token}> <div className={styles.token}>
<TokenIcon letter={meta.symbol[0]} color="var(--grad-center)" logo={COIN_ICONS[meta.symbol]} size={28} /> <TokenIcon letter={meta.symbol[0]} color="var(--grad-center)" logo={COIN_ICONS[meta.symbol]} size={28} />
@@ -174,11 +163,7 @@ export function StakeCard({ chain, amount, onAmountChange, onStake, isStaking }:
<div className={styles.bottom}> <div className={styles.bottom}>
<span className={styles.balance}> <span className={styles.balance}>
Баланс: {truncateDecimals(balanceHuman, 6)} {meta.symbol} Баланс: {truncateDecimals(balanceHuman, 6)} {meta.symbol}
<button <button className={styles.max} onClick={() => setPercent(100)} aria-label="Использовать весь баланс">
className={styles.max}
onClick={() => setPercent(100)}
aria-label="Использовать весь баланс"
>
МАКС МАКС
</button> </button>
</span> </span>
@@ -189,19 +174,13 @@ export function StakeCard({ chain, amount, onAmountChange, onStake, isStaking }:
loading={isFetching} loading={isFetching}
/> />
{insufficient && ( {belowGas && <p className={styles.hint}>Сумма меньше сетевого сбора (газ).</p>}
<p className={styles.hint}>
{gasShort
? `Недостаточно ${meta.symbol} на сетевую комиссию (газ).`
: 'Недостаточно средств для стейкинга с учётом комиссии и газа.'}
</p>
)}
<div className={`${styles.stakeBtn} ${!canStake ? styles.stakeBtnDisabled : ''}`}> <div className={`${styles.stakeBtn} ${!canStake ? styles.stakeBtnDisabled : ''}`}>
<PrimaryButton <PrimaryButton
label={isStaking ? 'Отправка…' : 'Застейкать'} label={isStaking ? 'Отправка…' : 'Застейкать'}
type="button" type="button"
onClick={onStake} onClick={() => onStake(stakeAmountHuman)}
disabled={!canStake} disabled={!canStake}
/> />
</div> </div>

View File

@@ -43,3 +43,17 @@
.highlight { .highlight {
color: var(--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;
}

View File

@@ -4,6 +4,8 @@ export interface QuoteRow {
label: string label: string
value: string value: string
accent?: 'success' | 'highlight' accent?: 'success' | 'highlight'
/** Финальный акцентный итог — отделяется разделителем и показывается крупнее. */
emphasis?: boolean
} }
interface Props { interface Props {
@@ -14,10 +16,12 @@ interface Props {
export function StakeQuotePanel({ rows, loading }: Props) { export function StakeQuotePanel({ rows, loading }: Props) {
return ( return (
<div className={styles.panel} data-loading={loading ? 'true' : undefined}> <div className={styles.panel} data-loading={loading ? 'true' : undefined}>
{rows.map(({ label, value, accent }) => ( {rows.map(({ label, value, accent, emphasis }) => (
<div key={label} className={styles.row}> <div key={label} className={`${styles.row} ${emphasis ? styles.rowTotal : ''}`}>
<span className={styles.label}>{label}</span> <span className={styles.label}>{label}</span>
<span className={`${styles.value} ${accent ? styles[accent] : ''}`}>{value}</span> <span className={`${styles.value} ${emphasis ? styles.valueLg : ''} ${accent ? styles[accent] : ''}`}>
{value}
</span>
</div> </div>
))} ))}
</div> </div>

View File

@@ -1,5 +1,6 @@
import { useState } from 'react' import { useState } from 'react'
import { Notification } from '@shared/ui' import { Notification } from '@shared/ui'
import { truncateDecimals } from '@shared/lib/utils/truncateDecimals'
import { ChainToggle } from './ChainToggle' import { ChainToggle } from './ChainToggle'
import { StakeCard } from './StakeCard' import { StakeCard } from './StakeCard'
import { PositionsPanel } from './PositionsPanel' import { PositionsPanel } from './PositionsPanel'
@@ -34,12 +35,16 @@ export function StakingWidget() {
setAmount('') setAmount('')
} }
function handleStake() { // stakeAmountHuman — это сумма стейка A (= введённое газ), её и отправляем на бэкенд.
function handleStake(stakeAmountHuman: string) {
stakeM.mutate( stakeM.mutate(
{ chain, amountHuman: amount }, { chain, amountHuman: stakeAmountHuman },
{ {
onSuccess: () => { onSuccess: () => {
setToast({ status: 'success', message: `Стейкинг ${amount} ${CHAIN_META[chain].symbol} выполнен` }) setToast({
status: 'success',
message: `Стейкинг ${truncateDecimals(stakeAmountHuman, 6)} ${CHAIN_META[chain].symbol} выполнен`,
})
setAmount('') setAmount('')
}, },
onError: (e) => setToast({ status: 'error', message: getStakingErrorMessage(e) }), onError: (e) => setToast({ status: 'error', message: getStakingErrorMessage(e) }),