add staking page

This commit is contained in:
2026-06-15 23:06:24 +03:00
parent 16ab237551
commit 7c3c0159e9
31 changed files with 2445 additions and 165 deletions

View File

@@ -18,6 +18,7 @@ import { PolitikaCookiePage } from '@pages/politika-cookie'
import { SoglasiePage } from '@pages/soglasie-personalnyh-dannyh'
import { ReestryPage } from '@pages/reestr-pd-rkn'
import { TransactionsPage } from '@pages/transactions'
import { StakingPage } from '@pages/staking'
import { WalletLayout } from '@widgets/wallet-layout'
import { ROUTES } from '@shared/config/routes'
import { ScrollToTop } from './ScrollToTop'
@@ -53,6 +54,7 @@ export function RouterProvider() {
<Route path={ROUTES.SWAP} element={<SwapPage />} />
<Route path={ROUTES.BRIDGE} element={<BridgePage />} />
<Route path={ROUTES.TRANSACTIONS} element={<TransactionsPage />} />
<Route path={ROUTES.STAKING} element={<StakingPage />} />
</Route>
<Route path={ROUTES.WALLET} element={<WalletPage />} />

View File

@@ -0,0 +1,271 @@
import { getCsrfToken } from '@shared/api/csrf'
import { tokenStore, refreshAccessToken } from '@shared/api/tokenStore'
import { toBaseUnits } from '@shared/lib/utils/baseUnits'
// Стейкинг живёт на том же custodial-хосте, что и кошелёк (см. features/wallet/api/walletApi.ts).
const WALLET_API_URL = 'https://app.cryptowallet.elcsa.ru'
export type StakeChain = 'ETH' | 'SOL'
/** Десятичность смолл-юнитов: ETH = wei (10^18), SOL = lamports (10^9). */
export const STAKE_DECIMALS: Record<StakeChain, number> = { ETH: 18, SOL: 9 }
// ── Низкоуровневые запросы (CSRF + Bearer + один silent refresh на 401) ──
async function walletGet<T>(path: string, allowRetry = true): Promise<T> {
const csrf = await getCsrfToken()
const bearer = tokenStore.get()
const res = await fetch(`${WALLET_API_URL}${path}`, {
credentials: 'include',
headers: {
'X-CSRF-Token': csrf,
...(bearer ? { Authorization: `Bearer ${bearer}` } : {}),
},
})
if (res.status === 401 && allowRetry) {
try {
await refreshAccessToken()
return walletGet<T>(path, false)
} catch {
tokenStore.clear()
throw new Error('Unauthorized')
}
}
const data = await res.json()
if (!res.ok) throw data
return data as T
}
async function walletPost<T>(
path: string,
body: unknown,
allowRetry = true,
extraHeaders: Record<string, string> = {}
): Promise<T> {
const csrf = await getCsrfToken()
const bearer = tokenStore.get()
const res = await fetch(`${WALLET_API_URL}${path}`, {
method: 'POST',
credentials: 'include',
headers: {
'Content-Type': 'application/json',
'X-CSRF-Token': csrf,
...(bearer ? { Authorization: `Bearer ${bearer}` } : {}),
...extraHeaders,
},
body: JSON.stringify(body),
})
if (res.status === 401 && allowRetry) {
try {
await refreshAccessToken()
return walletPost<T>(path, body, false, extraHeaders)
} catch {
tokenStore.clear()
throw new Error('Unauthorized')
}
}
const data = await res.json()
if (!res.ok) throw data
return data as T
}
/** Idempotency-Key для мутирующих операций (анти-дабл-спенд). */
function idempotency(): Record<string, string> {
return { 'Idempotency-Key': crypto.randomUUID() }
}
// ── 1.1 Quote ──
interface EthQuoteRaw {
amountWeiHuman: string
appFeeWeiHuman: string
stakeAmountWeiHuman: string
expectedStEthWeiHuman: string
aprPercent: number | null
}
interface SolQuoteRaw {
amountLamportsHuman: string
appFeeLamportsHuman: string
stakeLamportsHuman: string
validator: string
estApyPercent: number | null
}
/** Нормализованное превью стейка для UI (human-числа). */
export interface StakeQuote {
chain: StakeChain
amountHuman: string
feeHuman: string
afterFeeHuman: string
/** Ожидаемый stETH (ETH) либо делегируемая сумма (SOL). */
resultHuman: string
aprPercent: number | null
validator?: string
}
export async function getStakeQuote(chain: StakeChain, amountHuman: string): Promise<StakeQuote> {
const amount = toBaseUnits(amountHuman, STAKE_DECIMALS[chain])
if (chain === 'ETH') {
const res = await walletPost<{ success: boolean; data: EthQuoteRaw }>('/api/wallets/ETH/stake/quote', { amount })
const d = res.data
return {
chain,
amountHuman: d.amountWeiHuman,
feeHuman: d.appFeeWeiHuman,
afterFeeHuman: d.stakeAmountWeiHuman,
resultHuman: d.expectedStEthWeiHuman,
aprPercent: d.aprPercent,
}
}
const res = await walletPost<{ success: boolean; data: SolQuoteRaw }>('/api/wallets/SOL/stake/quote', { amount })
const d = res.data
return {
chain,
amountHuman: d.amountLamportsHuman,
feeHuman: d.appFeeLamportsHuman,
afterFeeHuman: d.stakeLamportsHuman,
resultHuman: d.stakeLamportsHuman,
aprPercent: d.estApyPercent,
validator: d.validator,
}
}
// ── 1.2 Stake ──
export async function stake(chain: StakeChain, amountHuman: string): Promise<void> {
const amount = toBaseUnits(amountHuman, STAKE_DECIMALS[chain])
await walletPost(`/api/wallets/${chain}/stake`, { amount }, true, idempotency())
}
// ── 1.3 Positions ──
export interface EthWithdrawalRequest {
requestId: string
amountStEthWeiHuman: string
isFinalized: boolean
isClaimed: boolean
}
export interface EthStakePositions {
chain: 'ETH'
protocol: string
stEthBalanceWeiHuman: string
aprPercent: number | null
withdrawalRequests: EthWithdrawalRequest[]
}
export type SolState = 'active' | 'activating' | 'deactivating' | 'inactive'
export interface SolPosition {
stakeAccount: string
lamportsHuman: string
state: SolState
validator: string
}
export interface SolStakePositions {
chain: 'SOL'
protocol: string
validator: string
positions: SolPosition[]
}
export async function getEthPositions(): Promise<EthStakePositions> {
const res = await walletGet<{ success: boolean; data: EthStakePositions }>('/api/wallets/ETH/stake/positions')
return res.data
}
export async function getSolPositions(): Promise<SolStakePositions> {
const res = await walletGet<{ success: boolean; data: SolStakePositions }>('/api/wallets/SOL/stake/positions')
return res.data
}
// ── 1.4 Unstake ──
export interface EthUnstakeResult {
mode: 'swap' | 'queue'
note?: string
}
export async function unstakeEth(amountHuman: string, mode: 'swap' | 'queue'): Promise<EthUnstakeResult> {
const amount = toBaseUnits(amountHuman, STAKE_DECIMALS.ETH)
const res = await walletPost<{ success: boolean; data: EthUnstakeResult }>(
'/api/wallets/ETH/unstake',
{ amount, mode },
true,
idempotency()
)
return res.data
}
export interface SolUnstakeResult {
action: 'deactivate' | 'withdraw'
note?: string
}
export async function unstakeSol(stakeAccount: string): Promise<SolUnstakeResult> {
const res = await walletPost<{ success: boolean; data: SolUnstakeResult }>(
'/api/wallets/SOL/unstake',
{ stakeAccount },
true,
idempotency()
)
return res.data
}
// ── 1.5 Claim (ETH, mode=queue) ──
export async function claimUnstake(requestId: string): Promise<void> {
await walletPost('/api/wallets/ETH/unstake/claim', { requestId }, true, idempotency())
}
// ── Предварительная проверка стоимости/баланса (op-cost) ──
// Read-only: считает комиссию 0.7% (в токене + нативе + USD) и проверяет, хватает ли
// средств на сумму И нативки на газ. Для нативного стейкинга token = символ сети.
export interface OpCostLeg {
symbol?: string
have?: string | number
need?: string | number
needReserve?: string | number
shortfall?: string | number
sufficient?: boolean | null
}
export interface OpCost {
appCommission?: { inToken?: number; inNative?: number; usd?: number | null }
balance?: { token?: OpCostLeg; gas?: OpCostLeg }
/** Хватает ли средств на сумму + газ. */
sufficient: boolean
}
export async function getOpCost(chain: StakeChain, amountHuman: string): Promise<OpCost> {
const res = await walletPost<{ success: boolean; data: OpCost }>(`/api/wallets/${chain}/op-cost`, {
token: chain,
amountHuman,
})
return res.data
}
// ── Ошибки ──
/** Достаёт человекочитаемый текст из конверта ошибки {success:false,error,code} или Error. */
export function getStakingErrorMessage(e: unknown): string {
if (e && typeof e === 'object' && 'error' in e) {
const msg = (e as { error?: unknown }).error
if (typeof msg === 'string') return msg
}
if (e instanceof Error) {
return e.message === 'Unauthorized' ? 'Сессия истекла — войдите снова.' : e.message
}
return 'Произошла ошибка. Попробуйте ещё раз.'
}

View File

@@ -0,0 +1,12 @@
export { useStakeQuote, useOpCost, useStakePositions, useStake, useUnstake, useClaim } from './model/useStaking'
export { getStakingErrorMessage, STAKE_DECIMALS } from './api/stakingApi'
export type {
StakeChain,
StakeQuote,
OpCost,
EthStakePositions,
EthWithdrawalRequest,
SolStakePositions,
SolPosition,
SolState,
} from './api/stakingApi'

View File

@@ -0,0 +1,76 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import {
getStakeQuote,
getOpCost,
getEthPositions,
getSolPositions,
stake,
unstakeEth,
unstakeSol,
claimUnstake,
type StakeChain,
type EthStakePositions,
type SolStakePositions,
type EthUnstakeResult,
type SolUnstakeResult,
} from '../api/stakingApi'
export function useStakeQuote(chain: StakeChain, amountHuman: string) {
const amount = parseFloat(amountHuman)
return useQuery({
queryKey: ['staking', 'quote', chain, amountHuman],
queryFn: () => getStakeQuote(chain, amountHuman),
enabled: Number.isFinite(amount) && amount > 0,
staleTime: 15_000,
retry: false,
})
}
export function useOpCost(chain: StakeChain, amountHuman: string) {
const amount = parseFloat(amountHuman)
return useQuery({
queryKey: ['staking', 'op-cost', chain, amountHuman],
queryFn: () => getOpCost(chain, amountHuman),
enabled: Number.isFinite(amount) && amount > 0,
staleTime: 15_000,
retry: false,
})
}
export function useStakePositions(chain: StakeChain) {
return useQuery<EthStakePositions | SolStakePositions>({
queryKey: ['staking', 'positions', chain],
queryFn: () => (chain === 'ETH' ? getEthPositions() : getSolPositions()),
staleTime: 30_000,
retry: false,
})
}
export function useStake() {
const qc = useQueryClient()
return useMutation({
mutationFn: ({ chain, amountHuman }: { chain: StakeChain; amountHuman: string }) => stake(chain, amountHuman),
onSuccess: (_data, { chain }) => qc.invalidateQueries({ queryKey: ['staking', 'positions', chain] }),
})
}
export type UnstakeVars =
| { chain: 'ETH'; amountHuman: string; mode: 'swap' | 'queue' }
| { chain: 'SOL'; stakeAccount: string }
export function useUnstake() {
const qc = useQueryClient()
return useMutation<EthUnstakeResult | SolUnstakeResult, Error, UnstakeVars>({
mutationFn: (vars) =>
vars.chain === 'ETH' ? unstakeEth(vars.amountHuman, vars.mode) : unstakeSol(vars.stakeAccount),
onSuccess: (_data, vars) => qc.invalidateQueries({ queryKey: ['staking', 'positions', vars.chain] }),
})
}
export function useClaim() {
const qc = useQueryClient()
return useMutation({
mutationFn: (requestId: string) => claimUnstake(requestId),
onSuccess: () => qc.invalidateQueries({ queryKey: ['staking', 'positions', 'ETH'] }),
})
}

View File

@@ -0,0 +1 @@
export { StakingPage } from './ui/StakingPage'

View File

@@ -0,0 +1,34 @@
.page {
width: 100%;
max-width: 960px;
margin: 0 auto;
padding: 32px 32px 48px;
}
.head {
margin-bottom: 24px;
}
.title {
font-size: 32px;
font-weight: 700;
color: var(--text-primary);
margin-bottom: 6px;
}
.subtitle {
font-size: 14px;
color: var(--text-secondary);
}
@media (max-width: 900px) {
.page {
padding: 24px 16px 32px;
}
}
@media (max-width: 640px) {
.title {
font-size: 26px;
}
}

View File

@@ -0,0 +1,14 @@
import { StakingWidget } from '@widgets/staking'
import styles from './StakingPage.module.css'
export function StakingPage() {
return (
<div className={styles.page}>
<header className={styles.head}>
<h1 className={styles.title}>Стейкинг</h1>
<p className={styles.subtitle}>Зарабатывайте на хранении ETH и SOL. Комиссия сервиса 0.7%.</p>
</header>
<StakingWidget />
</div>
)
}

View File

@@ -12,6 +12,7 @@ export const ROUTES = {
SEED_PHRASE: '/seed-phrase',
CONVERTER: '/converter',
KYC: '/kyc',
STAKING: '/staking',
RESTORE_PASSWORD: '/restore-password',
PUBLICHNAYA_OFERTA: '/publichnaya-oferta',
POLITIKA_PERSONALNYH_DANNYH: '/politika-personalnyh-dannyh',

View File

@@ -0,0 +1 @@
export { StakingWidget } from './ui/StakingWidget'

View File

@@ -0,0 +1,19 @@
import type { StakeChain, SolState } from '@features/staking'
/** Статические UI-метаданные по сети (всё динамическое приходит из API). */
export const CHAIN_META: Record<StakeChain, { symbol: string; aprLabel: 'APR' | 'APY'; protocol: string }> = {
ETH: { symbol: 'ETH', aprLabel: 'APR', protocol: 'Lido' },
SOL: { symbol: 'SOL', aprLabel: 'APY', protocol: 'Нативный' },
}
export const CHAIN_TABS: { chain: StakeChain; label: string }[] = [
{ chain: 'ETH', label: 'ETH · Lido' },
{ chain: 'SOL', label: 'SOL · нативный' },
]
export const SOL_STATE_META: Record<SolState, { label: string; variant: 'active' | 'ready' | 'muted' }> = {
active: { label: 'Активна', variant: 'active' },
activating: { label: 'Активируется', variant: 'muted' },
deactivating: { label: 'Деактивация', variant: 'muted' },
inactive: { label: 'Завершена', variant: 'ready' },
}

View File

@@ -0,0 +1,42 @@
.toggle {
display: inline-flex;
gap: 8px;
}
.tab {
padding: 10px 22px;
border-radius: 10px;
font-size: 14px;
font-weight: 700;
cursor: pointer;
border: none;
font-family: var(--font-sans);
letter-spacing: 0.3px;
transition: all 0.2s;
}
.active {
background: linear-gradient(135deg, var(--grad-edge), var(--grad-center));
color: var(--text-primary);
}
.inactive {
background: rgba(255, 255, 255, 0.06);
color: var(--text-secondary);
}
.inactive:hover {
color: var(--text-primary);
}
@media (max-width: 480px) {
.toggle {
width: 100%;
}
.tab {
flex: 1;
padding: 10px 0;
text-align: center;
}
}

View File

@@ -0,0 +1,24 @@
import type { StakeChain } from '@features/staking'
import { CHAIN_TABS } from '../model/meta'
import styles from './ChainToggle.module.css'
interface Props {
value: StakeChain
onChange: (chain: StakeChain) => void
}
export function ChainToggle({ value, onChange }: Props) {
return (
<div className={styles.toggle}>
{CHAIN_TABS.map(({ chain, label }) => (
<button
key={chain}
className={`${styles.tab} ${value === chain ? styles.active : styles.inactive}`}
onClick={() => onChange(chain)}
>
{label}
</button>
))}
</div>
)
}

View File

@@ -0,0 +1,168 @@
.panel {
background: var(--glass-bg);
border: 1px solid var(--glass-border);
border-radius: 20px;
padding: 24px;
display: flex;
flex-direction: column;
gap: 14px;
}
.head {
display: flex;
flex-direction: column;
gap: 4px;
}
.title {
font-size: 12px;
color: var(--text-secondary);
text-transform: uppercase;
letter-spacing: 1.2px;
font-weight: 700;
}
.total {
font-family: var(--font-mono);
font-size: 24px;
color: var(--text-primary);
}
.totalUnit {
font-size: 14px;
color: var(--text-secondary);
}
.subhead {
font-size: 11px;
color: var(--text-secondary);
text-transform: uppercase;
letter-spacing: 1px;
font-weight: 700;
margin-top: 4px;
}
.list {
display: flex;
flex-direction: column;
gap: 10px;
}
.pos {
background: var(--bg-deep);
border: 1px solid var(--glass-border);
border-radius: 14px;
padding: 14px;
}
.reqCard {
border-color: rgba(0, 212, 255, 0.25);
}
.posTop {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
margin-bottom: 10px;
}
.coin {
display: flex;
align-items: center;
gap: 8px;
font-weight: 700;
font-size: 14px;
color: var(--text-primary);
}
.amount {
font-family: var(--font-mono);
font-size: 18px;
color: var(--text-primary);
margin-bottom: 4px;
}
.meta {
font-size: 12px;
color: var(--text-secondary);
margin-bottom: 12px;
word-break: break-all;
}
.badge {
font-size: 11px;
font-weight: 700;
padding: 3px 10px;
border-radius: 999px;
white-space: nowrap;
}
.badgeActive {
color: var(--success);
background: rgba(38, 161, 123, 0.14);
}
.badgeReady {
color: var(--highlight);
background: rgba(0, 212, 255, 0.14);
}
.badgeMuted {
color: var(--text-secondary);
background: rgba(255, 255, 255, 0.08);
}
.btn {
width: 100%;
height: 40px;
border: 1px solid rgba(255, 255, 255, 0.14);
border-radius: 10px;
background: transparent;
color: var(--text-primary);
font-weight: 600;
font-size: 13px;
font-family: var(--font-sans);
cursor: pointer;
transition: background 0.2s;
}
.btn:hover {
background: rgba(255, 255, 255, 0.06);
}
.btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.btnPrimary {
border: none;
background: var(--success);
color: #fff;
font-weight: 700;
}
.btnPrimary:hover {
background: var(--success);
filter: brightness(1.12);
}
.note {
font-size: 12px;
color: var(--text-secondary);
text-align: center;
padding: 8px 0 2px;
}
.empty {
font-size: 13px;
color: var(--text-secondary);
text-align: center;
padding: 24px 0;
}
.amount + .btn,
.meta + .btn {
margin-top: 2px;
}

View File

@@ -0,0 +1,164 @@
import { Spinner, TokenIcon } from '@shared/ui'
import { COIN_ICONS } from '@shared/assets/coins'
import { truncateDecimals } from '@shared/lib/utils/truncateDecimals'
import { WithdrawNotice } from './WithdrawNotice'
import { useStakePositions, getStakingErrorMessage, type StakeChain, type SolPosition } from '@features/staking'
import { SOL_STATE_META } from '../model/meta'
import styles from './PositionsPanel.module.css'
interface Props {
chain: StakeChain
onUnstakeEth: (amountHuman: string) => void
onUnstakeSol: (position: SolPosition) => void
onClaim: (requestId: string) => void
unstakePending: boolean
claimPending: boolean
}
export function PositionsPanel({ chain, onUnstakeEth, onUnstakeSol, onClaim, unstakePending, claimPending }: Props) {
const { data, isLoading, isError, error } = useStakePositions(chain)
const badgeClass: Record<'active' | 'ready' | 'muted', string> = {
active: styles.badgeActive,
ready: styles.badgeReady,
muted: styles.badgeMuted,
}
const total =
data?.chain === 'ETH'
? data.stEthBalanceWeiHuman
: data?.chain === 'SOL'
? data.positions.reduce((sum, p) => sum + (parseFloat(p.lamportsHuman) || 0), 0).toString()
: '0'
const unit = chain === 'ETH' ? 'stETH' : 'SOL'
const ethEmpty =
data?.chain === 'ETH' && (parseFloat(data.stEthBalanceWeiHuman) || 0) === 0 && data.withdrawalRequests.length === 0
const solEmpty = data?.chain === 'SOL' && data.positions.length === 0
const empty = ethEmpty || solEmpty
return (
<div className={styles.panel}>
<div className={styles.head}>
<span className={styles.title}>Мои позиции</span>
<div className={styles.total}>
{truncateDecimals(total, 6)} <span className={styles.totalUnit}>{unit}</span>
</div>
</div>
<WithdrawNotice compact />
{isLoading && <Spinner label="Загрузка позиций" />}
{isError && <div className={styles.empty}>{getStakingErrorMessage(error)}</div>}
{!isLoading && !isError && empty && (
<div className={styles.empty}>У вас пока нет активных позиций стейкинга.</div>
)}
{data?.chain === 'ETH' && !empty && (
<>
{(parseFloat(data.stEthBalanceWeiHuman) || 0) > 0 && (
<div className={styles.list}>
<div className={styles.pos}>
<div className={styles.posTop}>
<span className={styles.coin}>
<TokenIcon letter="E" color="var(--grad-center)" logo={COIN_ICONS.ETH} size={24} />
stETH
</span>
<span className={`${styles.badge} ${styles.badgeActive}`}>
Активна{data.aprPercent != null ? ` · APR ${data.aprPercent}%` : ''}
</span>
</div>
<div className={styles.amount}>{truncateDecimals(data.stEthBalanceWeiHuman, 8)}</div>
<button
className={styles.btn}
onClick={() => onUnstakeEth(data.stEthBalanceWeiHuman)}
disabled={unstakePending}
>
Вывести
</button>
</div>
</div>
)}
{data.withdrawalRequests.length > 0 && (
<>
<div className={styles.subhead}>Заявки на вывод</div>
<div className={styles.list}>
{data.withdrawalRequests.map((r) => {
const ready = r.isFinalized && !r.isClaimed
return (
<div key={r.requestId} className={`${styles.pos} ${ready ? styles.reqCard : ''}`}>
<div className={styles.posTop}>
<span className={styles.coin}>Заявка #{r.requestId}</span>
<span
className={`${styles.badge} ${
r.isClaimed ? styles.badgeMuted : ready ? styles.badgeReady : styles.badgeMuted
}`}
>
{r.isClaimed ? 'Выведено' : ready ? 'Готово к выводу' : 'В очереди'}
</span>
</div>
<div className={styles.amount}>{truncateDecimals(r.amountStEthWeiHuman, 8)} ETH</div>
{ready ? (
<button
className={`${styles.btn} ${styles.btnPrimary}`}
onClick={() => onClaim(r.requestId)}
disabled={claimPending}
>
Забрать ETH
</button>
) : r.isClaimed ? null : (
<div className={styles.note}>Ожидает финализации в Lido</div>
)}
</div>
)
})}
</div>
</>
)}
</>
)}
{data?.chain === 'SOL' && !empty && (
<div className={styles.list}>
{data.positions.map((p) => {
const st = SOL_STATE_META[p.state]
return (
<div key={p.stakeAccount} className={styles.pos}>
<div className={styles.posTop}>
<span className={styles.coin}>
<TokenIcon letter="S" color="var(--grad-center)" logo={COIN_ICONS.SOL} size={24} />
SOL
</span>
<span className={`${styles.badge} ${badgeClass[st.variant]}`}>{st.label}</span>
</div>
<div className={styles.amount}>{truncateDecimals(p.lamportsHuman, 8)}</div>
<div className={styles.meta}>
Валидатор: {p.validator} · {p.stakeAccount}
</div>
{p.state === 'active' && (
<button className={styles.btn} onClick={() => onUnstakeSol(p)} disabled={unstakePending}>
Вывести
</button>
)}
{p.state === 'inactive' && (
<button
className={`${styles.btn} ${styles.btnPrimary}`}
onClick={() => onUnstakeSol(p)}
disabled={unstakePending}
>
Вывести средства
</button>
)}
{p.state === 'deactivating' && <div className={styles.note}>Разблокируется через 12 эпохи</div>}
{p.state === 'activating' && <div className={styles.note}>Активируется</div>}
</div>
)
})}
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,171 @@
.wrapper {
display: flex;
flex-direction: column;
gap: 1rem;
}
.pillsOuter {
display: none;
gap: 6px;
}
.card {
background: var(--glass-bg);
border: 1px solid var(--glass-border);
border-radius: 20px;
padding: 24px;
}
.top {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 18px;
}
.tag {
font-size: 12px;
color: var(--text-secondary);
text-transform: uppercase;
letter-spacing: 1.2px;
font-weight: 700;
}
.right {
display: flex;
align-items: center;
gap: 12px;
}
.apr {
font-size: 12px;
color: var(--success);
background: rgba(38, 161, 123, 0.12);
border: 1px solid rgba(38, 161, 123, 0.35);
border-radius: 999px;
padding: 4px 12px;
font-weight: 700;
white-space: nowrap;
}
.pillsInner {
display: flex;
gap: 6px;
}
.pill {
background: rgba(255, 255, 255, 0.07);
color: var(--text-secondary);
border: none;
border-radius: 999px;
padding: 5px 14px;
font-size: 12px;
cursor: pointer;
font-family: var(--font-sans);
font-weight: 600;
transition: all 0.2s;
}
.pill:hover {
color: var(--text-primary);
background: rgba(255, 255, 255, 0.13);
}
.mid {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 10px;
}
.input {
background: none;
border: none;
outline: none;
font-family: var(--font-mono);
font-size: 40px;
font-weight: 700;
color: var(--text-primary);
width: 100%;
min-width: 0;
}
.input::placeholder {
color: rgba(255, 255, 255, 0.15);
}
.token {
display: flex;
align-items: center;
gap: 8px;
flex-shrink: 0;
font-weight: 700;
font-size: 16px;
color: var(--text-primary);
}
.bottom {
display: flex;
align-items: center;
justify-content: flex-end;
margin-bottom: 20px;
}
.balance {
font-size: 13px;
color: var(--text-secondary);
display: flex;
align-items: center;
gap: 8px;
}
.max {
background: none;
border: none;
color: var(--interactive);
cursor: pointer;
font-weight: 700;
font-size: 13px;
font-family: var(--font-sans);
padding: 0;
}
.max:hover {
text-decoration: underline;
}
.hint {
margin: 14px 0 0;
font-size: 13px;
color: var(--error);
font-weight: 600;
line-height: 1.4;
}
.stakeBtn {
margin-top: 18px;
}
.stakeBtnDisabled {
opacity: 0.45;
}
@media (max-width: 650px) {
.card {
padding: 16px;
}
.input {
font-size: 32px;
}
.pillsOuter {
display: flex;
}
.pillsInner {
display: none;
}
}

View File

@@ -0,0 +1,162 @@
import { PrimaryButton, TokenIcon } from '@shared/ui'
import { COIN_ICONS } from '@shared/assets/coins'
import { truncateDecimals } from '@shared/lib/utils/truncateDecimals'
import { useDebounce } from '@shared/lib/hooks/useDebounce'
import { useWalletBalance } from '@features/wallet'
import { useStakeQuote, useOpCost, type StakeChain, type StakeQuote, type OpCost } from '@features/staking'
import { StakeQuotePanel, type QuoteRow } from './StakeQuotePanel'
import { CHAIN_META } from '../model/meta'
import styles from './StakeCard.module.css'
interface Props {
chain: StakeChain
amount: string
onAmountChange: (value: string) => void
onStake: () => void
isStaking: boolean
}
const PERCENTS = [25, 50, 100]
type Meta = (typeof CHAIN_META)[StakeChain]
const DASH = '—'
function usd(value: number): string {
return `$${value.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`
}
function buildRows(
meta: Meta,
quote: StakeQuote | undefined,
opCost: OpCost | undefined,
amountNum: number,
nativeUsdPrice: number
): QuoteRow[] {
const resultLabel = meta.symbol === 'ETH' ? 'Получите stETH' : 'Делегируется валидатору'
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 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
return [
{ label: 'Комиссия сервиса (0.7%)', value: fee },
{ label: 'Сетевой сбор (газ)', value: gas },
{ label: 'Сумма после комиссии', value: afterFee },
{ label: resultLabel, value: result, accent: 'highlight' },
{ label: 'Итого к списанию', value: totalUsd },
{ label: aprLabel, value: apr, accent: 'success' },
]
}
export function StakeCard({ chain, amount, onAmountChange, onStake, isStaking }: Props) {
const meta = CHAIN_META[chain]
const { data: balance } = useWalletBalance(chain)
const balanceHuman = balance?.native.formatted ?? '0'
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)
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
const gasShort = insufficient && opCost?.balance?.gas?.sufficient === false
const canStake = withinBalance && sufficient && !isStaking
const setPercent = (p: number) => onAmountChange(((balanceNum * p) / 100).toString())
const pills = (
<>
{PERCENTS.map((p) => (
<button key={p} className={styles.pill} onClick={() => setPercent(p)}>
{p}%
</button>
))}
</>
)
return (
<div className={styles.wrapper}>
<div className={styles.pillsOuter}>{pills}</div>
<div className={styles.card}>
<div className={styles.top}>
<span className={styles.tag}>Сумма стейка</span>
<div className={styles.right}>
<span className={styles.apr}>
{meta.aprLabel} {quote?.aprPercent != null ? `${quote.aprPercent}%` : '—'}
</span>
<div className={styles.pillsInner}>{pills}</div>
</div>
</div>
<div className={styles.mid}>
<input
className={styles.input}
type="text"
inputMode="decimal"
value={amount}
onChange={(e) => {
const v = e.target.value
if (/^(\d+\.?\d*|\.?\d*)$/.test(v) || v === '') onAmountChange(v)
}}
placeholder="0"
/>
<div className={styles.token}>
<TokenIcon letter={meta.symbol[0]} color="var(--grad-center)" logo={COIN_ICONS[meta.symbol]} size={28} />
<span>{meta.symbol}</span>
</div>
</div>
<div className={styles.bottom}>
<span className={styles.balance}>
Баланс: {truncateDecimals(balanceHuman, 6)} {meta.symbol}
<button className={styles.max} onClick={() => setPercent(100)}>
МАКС
</button>
</span>
</div>
<StakeQuotePanel
rows={buildRows(meta, quote, opCost, amountNum, balance?.native.usdPrice ?? 0)}
loading={isFetching}
/>
{insufficient && (
<p className={styles.hint}>
{gasShort
? `Недостаточно ${meta.symbol} на сетевую комиссию (газ).`
: 'Недостаточно средств для стейкинга с учётом комиссии и газа.'}
</p>
)}
<div className={`${styles.stakeBtn} ${!canStake ? styles.stakeBtnDisabled : ''}`}>
<PrimaryButton
label={isStaking ? 'Отправка…' : 'Застейкать'}
type="button"
onClick={onStake}
disabled={!canStake}
/>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,45 @@
.panel {
background: var(--glass-bg);
border: 1px solid var(--glass-border);
border-radius: 16px;
padding: 4px 18px;
transition: opacity 0.2s;
}
.panel[data-loading='true'] {
opacity: 0.5;
}
.row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 13px 0;
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
}
.row:last-child {
border-bottom: none;
}
.label {
font-size: 13px;
color: var(--text-secondary);
}
.value {
font-size: 14px;
color: var(--text-primary);
font-weight: 600;
font-family: var(--font-mono);
text-align: right;
}
.success {
color: var(--success);
}
.highlight {
color: var(--highlight);
}

View File

@@ -0,0 +1,25 @@
import styles from './StakeQuotePanel.module.css'
export interface QuoteRow {
label: string
value: string
accent?: 'success' | 'highlight'
}
interface Props {
rows: QuoteRow[]
loading?: boolean
}
export function StakeQuotePanel({ rows, loading }: Props) {
return (
<div className={styles.panel} data-loading={loading ? 'true' : undefined}>
{rows.map(({ label, value, accent }) => (
<div key={label} className={styles.row}>
<span className={styles.label}>{label}</span>
<span className={`${styles.value} ${accent ? styles[accent] : ''}`}>{value}</span>
</div>
))}
</div>
)
}

View File

@@ -0,0 +1,18 @@
.widget {
display: flex;
flex-direction: column;
gap: 20px;
}
.grid {
display: grid;
grid-template-columns: 1.2fr 1fr;
gap: 16px;
align-items: start;
}
@media (max-width: 1024px) {
.grid {
grid-template-columns: 1fr;
}
}

View File

@@ -0,0 +1,139 @@
import { useState } from 'react'
import { Notification } from '@shared/ui'
import { ChainToggle } from './ChainToggle'
import { StakeCard } from './StakeCard'
import { PositionsPanel } from './PositionsPanel'
import { UnstakeModal } from './UnstakeModal'
import {
useStake,
useUnstake,
useClaim,
getStakingErrorMessage,
type StakeChain,
type SolPosition,
} from '@features/staking'
import { CHAIN_META } from '../model/meta'
import styles from './StakingWidget.module.css'
type ModalState = { kind: 'eth'; amount: string } | { kind: 'sol'; position: SolPosition } | null
type Toast = { status: 'success' | 'error' | 'info'; message: string } | null
export function StakingWidget() {
const [chain, setChain] = useState<StakeChain>('ETH')
const [amount, setAmount] = useState('')
const [modal, setModal] = useState<ModalState>(null)
const [toast, setToast] = useState<Toast>(null)
const stakeM = useStake()
const unstakeM = useUnstake()
const claimM = useClaim()
function handleChain(next: StakeChain) {
setChain(next)
setAmount('')
}
function handleStake() {
stakeM.mutate(
{ chain, amountHuman: amount },
{
onSuccess: () => {
setToast({ status: 'success', message: `Стейкинг ${amount} ${CHAIN_META[chain].symbol} выполнен` })
setAmount('')
},
onError: (e) => setToast({ status: 'error', message: getStakingErrorMessage(e) }),
}
)
}
function handleConfirm(mode?: 'swap' | 'queue') {
if (modal?.kind === 'eth') {
const selected = mode ?? 'swap'
unstakeM.mutate(
{ chain: 'ETH', amountHuman: modal.amount, mode: selected },
{
onSuccess: () => {
setModal(null)
setToast({
status: 'success',
message:
selected === 'queue' ? 'Заявка на вывод добавлена в очередь Lido' : 'Вывод stETH → ETH выполнен',
})
},
onError: (e) => setToast({ status: 'error', message: getStakingErrorMessage(e) }),
}
)
} else if (modal?.kind === 'sol') {
unstakeM.mutate(
{ chain: 'SOL', stakeAccount: modal.position.stakeAccount },
{
onSuccess: (res) => {
setModal(null)
const withdrawn = 'action' in res && res.action === 'withdraw'
setToast({
status: 'success',
message: withdrawn ? 'Средства выведены на кошелёк' : 'Деактивация запущена — вывод через 12 эпохи',
})
},
onError: (e) => setToast({ status: 'error', message: getStakingErrorMessage(e) }),
}
)
}
}
function handleClaim(requestId: string) {
claimM.mutate(requestId, {
onSuccess: () => setToast({ status: 'success', message: 'ETH забран на кошелёк' }),
onError: (e) => setToast({ status: 'error', message: getStakingErrorMessage(e) }),
})
}
return (
<div className={styles.widget}>
<ChainToggle value={chain} onChange={handleChain} />
<div className={styles.grid}>
<StakeCard
chain={chain}
amount={amount}
onAmountChange={setAmount}
onStake={handleStake}
isStaking={stakeM.isPending}
/>
<PositionsPanel
chain={chain}
onUnstakeEth={(amountHuman) => setModal({ kind: 'eth', amount: amountHuman })}
onUnstakeSol={(position) => setModal({ kind: 'sol', position })}
onClaim={handleClaim}
unstakePending={unstakeM.isPending}
claimPending={claimM.isPending}
/>
</div>
{modal?.kind === 'eth' && (
<UnstakeModal
chain="ETH"
symbol="ETH"
amount={modal.amount}
pending={unstakeM.isPending}
onConfirm={handleConfirm}
onClose={() => setModal(null)}
/>
)}
{modal?.kind === 'sol' && (
<UnstakeModal
chain="SOL"
symbol="SOL"
amount={modal.position.lamportsHuman}
solState={modal.position.state}
pending={unstakeM.isPending}
onConfirm={handleConfirm}
onClose={() => setModal(null)}
/>
)}
{toast && <Notification status={toast.status} message={toast.message} onClose={() => setToast(null)} />}
</div>
)
}

View File

@@ -0,0 +1,156 @@
.overlay {
position: fixed;
inset: 0;
background: rgba(10, 11, 46, 0.75);
backdrop-filter: blur(4px);
display: flex;
align-items: center;
justify-content: center;
z-index: 100;
padding: 16px;
}
.card {
background: var(--bg-mid);
border: 1px solid var(--glass-border);
border-radius: 24px;
padding: 28px;
width: 100%;
max-width: 420px;
display: flex;
flex-direction: column;
gap: 20px;
}
.header {
display: flex;
align-items: center;
justify-content: space-between;
}
.title {
font-size: 18px;
font-weight: 700;
color: var(--text-primary);
}
.closeBtn {
background: none;
border: none;
color: var(--text-secondary);
font-size: 22px;
cursor: pointer;
line-height: 1;
padding: 0;
font-family: var(--font-sans);
}
.closeBtn:hover {
color: var(--text-primary);
}
.token {
background: var(--glass-bg);
border: 1px solid var(--glass-border);
border-radius: 14px;
padding: 16px 20px;
display: flex;
flex-direction: column;
gap: 4px;
}
.tokenLabel {
font-size: 11px;
text-transform: uppercase;
letter-spacing: 1px;
color: var(--text-secondary);
font-weight: 700;
}
.tokenAmount {
font-size: 24px;
font-weight: 700;
color: var(--text-primary);
font-family: var(--font-mono);
}
.modes {
display: flex;
gap: 10px;
}
.mode {
flex: 1;
display: flex;
flex-direction: column;
gap: 4px;
text-align: left;
background: var(--glass-bg);
border: 1px solid var(--glass-border);
border-radius: 12px;
padding: 12px;
cursor: pointer;
font-family: var(--font-sans);
transition: border-color 0.2s, background 0.2s;
}
.mode:hover {
border-color: rgba(255, 255, 255, 0.25);
}
.modeActive {
border-color: var(--interactive);
background: rgba(74, 109, 255, 0.1);
}
.modeTitle {
font-size: 14px;
font-weight: 700;
color: var(--text-primary);
}
.modeDesc {
font-size: 11px;
color: var(--text-secondary);
line-height: 1.3;
}
.solNote {
margin: 0;
font-size: 13px;
color: var(--text-secondary);
line-height: 1.45;
}
.confirmBtn {
width: 100%;
height: 56px;
background: linear-gradient(135deg, var(--grad-edge), var(--grad-center));
border: none;
border-radius: 14px;
color: var(--text-primary);
font-size: 16px;
font-weight: 700;
cursor: pointer;
font-family: var(--font-sans);
letter-spacing: 0.3px;
transition: filter 0.25s, box-shadow 0.25s;
}
.confirmBtn:hover {
filter: brightness(1.15);
box-shadow: 0 0 24px rgba(91, 61, 184, 0.5);
}
.confirmBtn:disabled {
opacity: 0.6;
cursor: not-allowed;
filter: none;
box-shadow: none;
}
@media (max-width: 480px) {
.modes {
flex-direction: column;
}
}

View File

@@ -0,0 +1,96 @@
import { useEffect, useState } from 'react'
import { WithdrawNotice } from './WithdrawNotice'
import type { StakeChain, SolState } from '@features/staking'
import styles from './UnstakeModal.module.css'
interface Props {
chain: StakeChain
/** Тикер сети (ETH / SOL). */
symbol: string
/** Сумма к выводу (human). */
amount: string
/** Состояние SOL-позиции (для двухфазного вывода). */
solState?: SolState
/** Идёт ли запрос вывода. */
pending?: boolean
onConfirm: (mode?: 'swap' | 'queue') => void
onClose: () => void
}
export function UnstakeModal({ chain, symbol, amount, solState, pending, onConfirm, onClose }: Props) {
const [mode, setMode] = useState<'swap' | 'queue'>('swap')
useEffect(() => {
function onKey(e: KeyboardEvent) {
if (e.key === 'Escape') onClose()
}
document.addEventListener('keydown', onKey)
return () => document.removeEventListener('keydown', onKey)
}, [onClose])
const isSolWithdraw = chain === 'SOL' && solState === 'inactive'
const confirmLabel = pending
? 'Отправка…'
: chain === 'ETH'
? 'Подтвердить вывод'
: isSolWithdraw
? 'Вывести средства'
: 'Запустить деактивацию'
const showNotice = chain === 'SOL' || (chain === 'ETH' && mode === 'queue')
return (
<div className={styles.overlay} onClick={onClose}>
<div className={styles.card} onClick={(e) => e.stopPropagation()}>
<div className={styles.header}>
<span className={styles.title}>Вывод из стейкинга</span>
<button className={styles.closeBtn} onClick={onClose}>
×
</button>
</div>
<div className={styles.token}>
<span className={styles.tokenLabel}>Сумма</span>
<span className={styles.tokenAmount}>
{amount} {chain === 'ETH' ? 'stETH' : symbol}
</span>
</div>
{chain === 'ETH' && (
<div className={styles.modes}>
<button
className={`${styles.mode} ${mode === 'swap' ? styles.modeActive : ''}`}
onClick={() => setMode('swap')}
>
<span className={styles.modeTitle}>Мгновенно</span>
<span className={styles.modeDesc}>Своп stETH ETH через Curve</span>
</button>
<button
className={`${styles.mode} ${mode === 'queue' ? styles.modeActive : ''}`}
onClick={() => setMode('queue')}
>
<span className={styles.modeTitle}>Очередь</span>
<span className={styles.modeDesc}>Заявка в Lido Withdrawal Queue</span>
</button>
</div>
)}
{chain === 'SOL' && !isSolWithdraw && (
<p className={styles.solNote}>
Средства разблокируются через 12 эпохи. После завершения вернитесь и подтвердите вывод.
</p>
)}
{showNotice && <WithdrawNotice />}
<button
className={styles.confirmBtn}
onClick={() => onConfirm(chain === 'ETH' ? mode : undefined)}
disabled={pending}
>
{confirmLabel}
</button>
</div>
</div>
)
}

View File

@@ -0,0 +1,32 @@
.notice {
display: flex;
align-items: flex-start;
gap: 10px;
background: rgba(0, 212, 255, 0.08);
border: 1px solid rgba(0, 212, 255, 0.3);
border-radius: 12px;
padding: 12px 14px;
font-size: 13px;
line-height: 1.4;
color: var(--text-secondary);
}
.icon {
flex-shrink: 0;
width: 18px;
height: 18px;
border-radius: 50%;
background: var(--highlight);
color: var(--bg-deep);
font-weight: 800;
font-size: 12px;
display: flex;
align-items: center;
justify-content: center;
line-height: 1;
}
.compact {
padding: 10px 12px;
font-size: 12px;
}

View File

@@ -0,0 +1,14 @@
import styles from './WithdrawNotice.module.css'
interface Props {
compact?: boolean
}
export function WithdrawNotice({ compact }: Props) {
return (
<div className={`${styles.notice} ${compact ? styles.compact : ''}`}>
<span className={styles.icon} aria-hidden="true">!</span>
<span>Вывод средств из стейкинга может занимать до 2&nbsp;недель.</span>
</div>
)
}

View File

@@ -92,6 +92,9 @@ export function WalletHeader() {
<Link to={ROUTES.WALLET} className={styles.dropdownItem} onClick={() => setOpen(false)}>
Кошелёк
</Link>
<Link to={ROUTES.STAKING} className={styles.dropdownItem} onClick={() => setOpen(false)}>
Стейкинг
</Link>
<Link to={ROUTES.TRANSACTIONS} className={styles.dropdownItem} onClick={() => setOpen(false)}>
Транзакции
</Link>