@@ -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 ,
amount Num : number ,
total Num : 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 ( f alse )
const totalBase = BigInt ( toBaseUnits ( totalHuman , decimals ) )
const gasBase = BigInt ( toBaseUnits ( gasReserveHuman , decimals ) )
const stakeBase = totalBase > gasBase ? totalBase - gasBase : 0n
return fromBaseUnits ( stakeBase . toString ( ) , decim als)
}
} , [ 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 ) = > (
< button
key = { p }
className = { styles . pill }
onClick = { ( ) = > setPercent ( p ) }
aria-label = { ` ${ p } % от баланса ` }
>
< button key = { p } className = { styles . pill } onClick = { ( ) = > setPercent ( p ) } aria-label = { ` ${ p } % от баланса ` } >
{ p } %
< / button >
) ) }
@@ -143,7 +132,7 @@ export function StakeCard({ chain, amount, onAmountChange, onStake, isStaking }:
< div className = { styles . card } >
< div className = { styles . top } >
< span className = { styles . tag } > С у м м а с т е й к а < / span >
< span className = { styles . tag } > С у м м а к с п и с а н и ю < / span >
< div className = { styles . right } >
< span className = { styles . apr } >
{ 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 } ` }
/ >
< div className = { styles . token } >
< 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 } >
< span className = { styles . balance } >
Б а л а н с : { truncateDecimals ( balanceHuman , 6 ) } { meta . symbol }
< button
className = { styles . max }
onClick = { ( ) = > setPercent ( 100 ) }
aria-label = "Использовать весь баланс"
>
< button className = { styles . max } onClick = { ( ) = > setPercent ( 100 ) } aria-label = "Использовать весь баланс" >
М А К С
< / button >
< / span >
@@ -189,19 +174,13 @@ export function StakeCard({ chain, amount, onAmountChange, onStake, isStaking }:
loading = { isFetching }
/ >
{ insufficient && (
< p className = { styles . hint } >
{ gasShort
? ` Недостаточно ${ meta . symbol } на сетевую комиссию (газ). `
: 'Недостаточно средств для стейкинга с учётом комиссии и газа.' }
< / p >
) }
{ belowGas && < p className = { styles . hint } > С у м м а м е н ь ш е с е т е в о г о с б о р а ( г а з ) . < / p > }
< div className = { ` ${ styles . stakeBtn } ${ ! canStake ? styles . stakeBtnDisabled : '' } ` } >
< PrimaryButton
label = { isStaking ? 'Отправка…' : 'Застейкать' }
type = "button"
onClick = { onStake }
onClick = { ( ) = > onStake ( stakeAmountHuman ) }
disabled = { ! canStake }
/ >
< / div >