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

590
CryptoWallet-DeFi-Guide.md Normal file
View File

@@ -0,0 +1,590 @@
# CryptoWallet DeFi API — гайд для фронтендера
Сектор DeFi: **Стейкинг** · **Пулы (Uniswap v3 LP)** · **Отвязка кошельков (approvals)**
Базовый URL: `https://app.cryptowallet.elcsa.ru/api`
---
## Общие правила (читать один раз)
**Авторизация:** все запросы требуют `Authorization: Bearer <JWT>`.
**CSRF (для всех `POST`):** double-submit —
- cookie `csrf_token=<value>` (сервер выдаёт)
- header `X-CSRF-Token: <тот же value>`
**Idempotency-Key (для мутирующих `POST`: stake/unstake/claim/lp add/remove/revoke/revoke-all):**
заголовок `Idempotency-Key: <uuid>`. Сгенерь UUID на фронте; при retry шли **тот же** — сервер вернёт
кэшированный ответ, не выполнит операцию дважды (анти-дабл-спенд).
**Конверт ответа:**
- успех → `{ "success": true, "data": { ... } }`
- ошибка → `{ "success": false, "error": "текст", "code": "OPTIONAL_CODE" }`
**Кастодиальность:** ключи на сервере. Все транзакции подписывает и броадкастит сервер мнемоникой
пользователя — фронту **не нужно ничего подписывать**. Достаточно вызвать endpoint.
**Числа — два формата.** Почти у каждого «сырого» поля сумм есть человекочитаемый спутник:
- `amount0Desired` (smallest units, строка) + `amount0DesiredHuman` (обычное число, строка).
- Для UI бери `*Human`. Для повторной отправки на сервер — сырое поле.
**Газ.** Любая мутация тратит газ нативной монетой (ETH для ETH/LP, SOL для Solana). Если на кошельке
нет нативки — операция упадёт. Проверить заранее можно через баланс в LP-квоте (см. 2.2) или
`POST /wallets/{chain}/op-cost` (см. примечание в конце).
**Комиссия сервиса:** 0.7% (70 bps) на стейк и на LP-депозит. На **вывод** (unstake / lp remove /
revoke) комиссии нет.
---
# 1. СТЕЙКИНГ
ETH → **Lido** (stETH). SOL → **нативный** стейкинг (StakeProgram, делегирование валидатору).
`chain` в пути = `ETH` или `SOL`.
---
### 1.1. `POST /wallets/{chain}/stake/quote`
**Что делает:** превью стейка — считает комиссию 0.7%, сумму после комиссии, ожидаемый результат и
доходность. Ничего не подписывает.
**Вход (body):**
```json
{ "amount": "100000000000000000" }
```
- `amount` — сумма в smallest units (ETH = wei 10^18; SOL = lamports 10^9), строка, > 0.
**Возвращает (ETH):**
```json
{ "success": true, "data": {
"amountWei": "100000000000000000", "amountWeiHuman": "0.1",
"appFeeWei": "700000000000000", "appFeeWeiHuman": "0.0007",
"stakeAmountWei": "99300000000000000", "stakeAmountWeiHuman": "0.0993",
"expectedStEthWei": "99300000000000000","expectedStEthWeiHuman": "0.0993",
"aprPercent": 3.1
}}
```
**Возвращает (SOL):**
```json
{ "success": true, "data": {
"amountLamports": "1000000000", "amountLamportsHuman": "1",
"appFeeLamports": "7000000", "appFeeLamportsHuman": "0.007",
"stakeLamports": "993000000", "stakeLamportsHuman": "0.993",
"validator": "<vote-pubkey валидатора>",
"estApyPercent": 6.5
}}
```
**Примечание:** `aprPercent` (ETH) может быть `null`, если внешний источник APR недоступен — это не
блокирует стейк. Lido даёт stETH ~1:1 к внесённому ETH (после комиссии).
---
### 1.2. `POST /wallets/{chain}/stake`
**Что делает:** выполняет стейк. ETH: списывает комиссию 0.7% → `Lido.submit()`. SOL: создаёт
stake-аккаунт + делегирует валидатору (+ комиссия). Подписывает и броадкастит сам.
**Вход:** header `Idempotency-Key: <uuid>` + body:
```json
{ "amount": "100000000000000000" }
```
- `amount` — то же, что в quote (smallest units).
**Возвращает (ETH):**
```json
{ "success": true, "data": {
"feeTxid": "0x...", "stakeTxid": "0x...",
"stakeAmountWei": "99300000000000000", "stakeAmountWeiHuman": "0.0993",
"appFeeWei": "700000000000000", "appFeeWeiHuman": "0.0007"
}}
```
**Возвращает (SOL):**
```json
{ "success": true, "data": {
"stakeTxid": "<signature>",
"stakeAccount": "<pubkey нового stake-аккаунта>",
"stakeLamports": "993000000", "stakeLamportsHuman": "0.993",
"appFeeLamports": "7000000", "appFeeLamportsHuman": "0.007",
"validator": "<vote-pubkey>"
}}
```
**Примечание:** `stakeAccount` (SOL) **сохрани** — он нужен для unstake и для отображения позиции.
---
### 1.3. `GET /wallets/{chain}/stake/positions`
**Что делает:** читает текущие позиции стейкинга прямо из блокчейна (без БД).
**Вход:** только `chain` в пути. Тела нет.
**Возвращает (ETH):**
```json
{ "success": true, "data": {
"protocol": "lido", "chain": "ETH",
"stEthBalanceWei": "99300000000000000", "stEthBalanceWeiHuman": "0.0993",
"aprPercent": 3.1,
"withdrawalRequests": [
{ "requestId": "12345", "amountStEthWei": "50000000000000000",
"amountStEthWeiHuman": "0.05", "isFinalized": true, "isClaimed": false }
]
}}
```
**Возвращает (SOL):**
```json
{ "success": true, "data": {
"protocol": "native", "chain": "SOL", "validator": "<vote-pubkey>",
"positions": [
{ "stakeAccount": "<pubkey>", "lamports": "993000000", "lamportsHuman": "0.993",
"state": "active", "validator": "<vote-pubkey>",
"delegatedLamports": "993000000", "delegatedLamportsHuman": "0.993" }
]
}}
```
**Примечание:**
- ETH `withdrawalRequests` — это заявки на вывод через очередь (mode=queue). Когда `isFinalized:true` и
`isClaimed:false` → можно забирать через `/unstake/claim` (см. 1.5).
- SOL `state``active | activating | deactivating | inactive`. Unstake зависит от состояния (см. 1.4).
---
### 1.4. `POST /wallets/{chain}/unstake`
**Что делает:** вывод из стейкинга. **Без комиссии.**
- ETH: `mode=swap` — мгновенно через Curve (stETH→ETH); `mode=queue` — заявка в Lido Withdrawal Queue.
- SOL: один endpoint, два шага по состоянию: `active`→деактивация (cooldown 1-2 эпохи), затем повторный
вызов → `withdraw`.
**Вход:** header `Idempotency-Key: <uuid>` + body:
ETH:
```json
{ "amount": "50000000000000000", "mode": "swap" }
```
- `amount` — сумма stETH в wei.
- `mode``"swap"` (мгновенно, дефолт) или `"queue"` (очередь Lido).
SOL:
```json
{ "stakeAccount": "<pubkey stake-аккаунта>" }
```
- `stakeAccount` — адрес из позиции (1.3) или из ответа stake (1.2).
**Возвращает (ETH, mode=swap):**
```json
{ "success": true, "data": {
"mode": "swap", "approveTxid": "0x...", "swapTxid": "0x...",
"swapAmountStEthWei": "50000000000000000", "swapAmountStEthWeiHuman": "0.05",
"minEthOutWei": "49800000000000000", "minEthOutWeiHuman": "0.0498"
}}
```
**Возвращает (ETH, mode=queue):**
```json
{ "success": true, "data": {
"mode": "queue", "approveTxid": "0x...", "requestTxid": "0x...",
"requestedStEthWei": "50000000000000000", "requestedStEthWeiHuman": "0.05",
"note": "requestId появится в /stake/positions; затем /unstake/claim когда isFinalized"
}}
```
**Возвращает (SOL):**
```json
{ "success": true, "data": {
"action": "deactivate",
"txid": "<signature>",
"note": "Деактивация запущена. Через 1-2 эпохи вызови /unstake снова — выведет средства."
}}
```
либо после cooldown:
```json
{ "success": true, "data": {
"action": "withdraw", "txid": "<signature>",
"withdrawnLamports": "993000000", "withdrawnLamportsHuman": "0.993"
}}
```
**Примечание:**
- SOL unstake — **двухфазный**. Первый вызов на `active`-аккаунте вернёт `action:"deactivate"`. Покажи
юзеру «средства разблокируются через 1-2 эпохи». Когда `state` станет `inactive` — повторный вызов
вернёт `action:"withdraw"`.
- Если вызвать SOL unstake пока `state:"deactivating"` → 400 (ещё рано, идёт cooldown).
---
### 1.5. `POST /wallets/ETH/unstake/claim`
**Что делает:** забирает ETH по готовой заявке из Lido Withdrawal Queue (только ETH, только mode=queue).
**Вход:** header `Idempotency-Key: <uuid>` + body:
```json
{ "requestId": "12345" }
```
- `requestId` — id заявки из `/stake/positions.withdrawalRequests`, у которой `isFinalized:true`.
**Возвращает:**
```json
{ "success": true, "data": { "claimTxid": "0x..." } }
```
**Примечание:** сервер заранее проверяет, что `requestId` принадлежит юзеру и существует — иначе 400
(без потери газа). Доступно только при `isFinalized:true`.
---
# 2. ПУЛЫ — Uniswap v3 LP (только ETH)
Курируемый список пулов (например WETH/USDT, WBTC/WETH, XAUt/USDT). Пользователь выбирает пул,
диапазон цен и суммы — сервер минтит позицию (NFT). Путь всегда `chain=ETH`.
---
### 2.1. `GET /wallets/ETH/lp/pools`
**Что делает:** список курируемых пулов со статистикой (TVL, объём, APR) и текущей ценой.
**Вход:** нет тела.
**Возвращает:**
```json
{ "success": true, "data": [
{
"poolAddress": "0x4e68ccd3e89f51c3074ca5072bbac773960dfa36",
"token0": "0xC02a...", "token1": "0xdAC1...",
"symbol0": "WETH", "symbol1": "USDT",
"decimals0": 18, "decimals1": 6,
"feeTier": 3000,
"currentPrice": 1665.75,
"currentTick": -202300,
"tvlUSD": 33000000, "volumeUSD24h": 5400000, "aprPercent": 40.33
}
]}
```
**Примечание:**
- `feeTier` в сотых долях процента (3000 = 0.3%, 500 = 0.05%).
- `currentPrice` = сколько token1 за 1 token0 (та же конвенция, что в quote ниже).
- `tvlUSD`/`volumeUSD24h`/`aprPercent` могут быть `null`, если внешняя статистика недоступна (не
блокирует депозит).
---
### 2.2. `POST /wallets/ETH/lp/quote` ⭐ (с комиссией и проверкой баланса)
**Что делает:** превью депозита — по диапазону цен и желаемым суммам считает тики, фактические суммы,
slippage-минимумы, APR, **нашу комиссию 0.7% (в токенах + USD + ETH)** и **достаточность баланса**
(хватает ли token0/token1 на взнос И ETH на газ). Ничего не подписывает.
**Вход (body):**
```json
{
"pool": "0x4e68ccd3e89f51c3074ca5072bbac773960dfa36",
"priceLower": 1568.75,
"priceUpper": 1665.75,
"amount0Desired": "100000000000000000",
"amount1Desired": "263359",
"slippageBps": 100,
"useNativeEth": true
}
```
- `pool` — адрес из `/lp/pools`.
- `priceLower` / `priceUpper` — границы диапазона (token1 за token0).
- `amount0Desired` / `amount1Desired` — желаемые суммы в smallest units (по decimals каждого токена).
- `slippageBps` — опционально, 0..5000 (дефолт 100 = 1%).
- `useNativeEth` — опционально. Если у пула есть нога WETH, а у юзера на балансе **ETH** (не WETH) —
поставь `true`: сервер сам завернёт ETH→WETH. Влияет на проверку баланса (проверяет ETH вместо WETH).
**Возвращает:**
```json
{ "success": true, "data": {
"poolAddress": "0x4e68ccd3...",
"tickLower": -202740, "tickUpper": -202140,
"priceLower": 1568.75, "priceUpper": 1665.75,
"amount0Desired": "99999999999872", "amount0DesiredHuman": "0.000099999999999872",
"amount1Desired": "263359", "amount1DesiredHuman": "0.263359",
"amount0Min": "98999999999873", "amount0MinHuman": "0.000098999999999873",
"amount1Min": "260725", "amount1MinHuman": "0.260725",
"liquidity": "354877171283",
"aprPercent": 40.33,
"appFee0Human": "0.0007", "appFee1Human": "0.001843",
"appFee0Usd": 1.20, "appFee1Usd": 1.84,
"appFeeTotalUsd": 3.04,
"appFeeTotalEth": "0.00182",
"balance": {
"token0": { "symbol": "WETH", "have": "0.5", "need": "0.0001", "sufficient": true, "shortfall": "0" },
"token1": { "symbol": "USDT", "have": "0", "need": "0.263359", "sufficient": false, "shortfall": "0.263359" },
"gas": { "symbol": "ETH", "have": "0.02", "needReserve": "0.001", "sufficient": true },
"sufficient": false
}
}}
```
**Примечание:**
- **Комиссия:** `appFee0Human`/`appFee1Human` — 0.7% по каждой ноге в самом токене. `appFeeTotalUsd`
суммарно в долларах, `appFeeTotalEth` — суммарно в ETH (монета сети). USD/ETH могут быть `null`, если у
токена нет цены в whitelist (например XAUt) — это не ошибка.
- **Баланс:** показывай красным, если `balance.sufficient === false`. `sufficient` у ноги может быть
`null` (не проверялось / нога покрыта нативным ETH при `useNativeEth`) — это не «не хватает».
- `balance` присутствует только если у юзера есть ETH-кошелёк (он есть всегда у custodial-юзера).
- Реальные суммы депозита = `amount0DesiredHuman`/`amount1DesiredHuman` (могут быть меньше введённых —
ограничивает диапазон).
---
### 2.3. `POST /wallets/ETH/lp/add`
**Что делает:** депозит в пул. Списывает комиссию 0.7% по каждому токену → approve×2 → `NFPM.mint()`.
При `useNativeEth` сначала заворачивает ETH→WETH. Возвращает `tokenId` позиции.
**Вход:** header `Idempotency-Key: <uuid>` + body **тот же, что в quote** (`pool`, `priceLower`,
`priceUpper`, `amount0Desired`, `amount1Desired`, `slippageBps?`, `useNativeEth?`).
**Возвращает:**
```json
{ "success": true, "data": {
"feeTxids": ["0x...", "0x..."],
"approveTxids": ["0x...", "0x..."],
"mintTxid": "0x...",
"wrapTxid": "0x... (если useNativeEth)",
"tickLower": -202740, "tickUpper": -202140,
"appFee0": "700000000000", "appFee0Human": "0.0007",
"appFee1": "1843", "appFee1Human": "0.001843"
}}
```
**Примечание:** сервер делает pre-check баланса ОБОИХ токенов ДО любых транзакций — если не хватает
второй ноги, операция падает с 400 **до** wrap/комиссии (ничего не теряется). Поэтому сначала всегда
показывай `quote` с `balance.sufficient`.
---
### 2.4. `POST /wallets/ETH/lp/remove`
**Что делает:** выводит ликвидность из позиции (`decreaseLiquidity` + `collect` тела и накопленных
комиссий). При `percent=100` ещё и сжигает NFT (`burn`). **Без комиссии.**
**Вход:** header `Idempotency-Key: <uuid>` + body:
```json
{ "tokenId": "123456", "percent": 100 }
```
- `tokenId` — id позиции из `/lp/positions`.
- `percent` — опционально, (0..100], дефолт 100.
**Возвращает:**
```json
{ "success": true, "data": {
"removeTxid": "0x...",
"removedLiquidity": "354877171283",
"burned": true
}}
```
**Примечание:** сервер проверяет, что `tokenId` принадлежит кошельку юзера (`ownerOf`), иначе 400.
---
### 2.5. `GET /wallets/ETH/lp/positions`
**Что делает:** читает LP-позиции юзера (NFT) в курируемых пулах прямо из блокчейна: диапазон, текущая
стоимость по каждому токену, накопленные комиссии, в диапазоне ли цена.
**Вход:** нет тела.
**Возвращает:**
```json
{ "success": true, "data": {
"chain": "ETH",
"positions": [
{
"tokenId": "123456",
"poolAddress": "0x4e68...", "symbol0": "WETH", "symbol1": "USDT", "feeTier": 3000,
"tickLower": -202740, "tickUpper": -202140,
"priceLower": 1568.75, "priceUpper": 1665.75,
"liquidity": "354877171283",
"currentAmount0": "99000000000000", "currentAmount0Human": "0.000099",
"currentAmount1": "260000", "currentAmount1Human": "0.26",
"feesOwed0": "1200000000", "feesOwed0Human": "0.0000000012",
"feesOwed1": "500", "feesOwed1Human": "0.0005",
"inRange": true
}
],
"totalNfts": 3, "scanned": 3, "truncated": false
}}
```
**Примечание:**
- `inRange:false` → цена вышла за диапазон, позиция временно не зарабатывает комиссии (покажи ⚠️).
- `feesOwed*` — накопленные комиссии, которые заберутся при `remove`.
- `truncated:true`у юзера слишком много NFT, показаны не все (на практике редко).
---
# 3. ОТВЯЗКА КОШЕЛЬКОВ (Approvals) — ETH / BSC
Назначение: ERC20-approval — это «разрешение» контракту тратить токен. Повисший **unlimited** approval
(MAX) на роутер/солвер позволяет внешней стороне выводить будущие поступления токена → деньги «утекают».
Этот блок даёт **увидеть** открытые двери и **закрыть** их. `chain` = `ETH` или `BSC`.
---
### 3.1. `GET /wallets/{chain}/approvals`
**Что делает:** сканирует кошелёк по известным спендерам (Relay-роутеры, Permit2, Uniswap NFPM) ×
известным токенам и возвращает только **ненулевые** allowance (открытые двери) с флагом `unlimited`.
Read-only, ничего не подписывает.
**Вход:** только `chain` в пути.
**Возвращает:**
```json
{ "success": true, "data": {
"chain": "ETH",
"owner": "0x9dB8...21ea9",
"approvals": [
{
"token": "USDT", "tokenAddress": "0xdAC1...",
"spender": "Relay Router v2", "spenderAddress": "0xb92f...",
"allowance": "115792089237316195423570985008687907853269984665640564039457584007913129639935",
"allowanceHuman": "unlimited",
"unlimited": true
}
]
}}
```
**Примечание:**
- Пустой `approvals: []` → все двери закрыты ✅.
- `unlimited:true``allowanceHuman:"unlimited"`) — главный риск, выделяй красным. Конечный allowance
(например ровно на сумму свопа) низкорисковый.
---
### 3.2. `POST /wallets/{chain}/approvals/revoke`
**Что делает:** отзывает ОДНУ конкретную дверь — сервер подписывает `token.approve(spender, 0)`.
Сумма **всегда 0** (можно только отозвать, не выдать).
**Вход:** header `Idempotency-Key: <uuid>` + body:
```json
{
"token": "0xdAC17F958D2ee523a2206206994597C13D831ec7",
"spender": "0xb92fe925dc43a0ecde6c8b1a2709c170ec4fff4f"
}
```
- `token` — адрес ERC20 (поле `tokenAddress` из скана).
- `spender` — адрес спендера (поле `spenderAddress` из скана).
**Возвращает:**
```json
{ "success": true, "data": {
"chain": "ETH", "token": "0xdAC1...", "spender": "0xb92f...",
"txid": "0x...", "previousAllowance": "1157920..."
}}
```
либо, если уже было 0:
```json
{ "success": true, "data": { "txid": null, "alreadyZero": true,
"message": "Allowance already 0 — nothing to revoke" } }
```
**Примечание:** если allowance уже 0 — газ не тратится (`alreadyZero:true`, `txid:null`). Иначе нужен
небольшой газ (нативка) на одну транзакцию.
---
### 3.3. `POST /wallets/{chain}/approvals/revoke-all` ⭐ «отвязать от всего»
**Что делает:** одним вызовом закрывает **ВСЕ** открытые двери: сервер сам сканирует ненулевые
allowance и последовательно подписывает `approve(spender, 0)` по каждой.
**Вход:** header `Idempotency-Key: <uuid>` + body (опционально):
```json
{ "onlyUnlimited": false }
```
- `onlyUnlimited``false` (дефолт) = отозвать ВСЕ ненулевые; `true` = только unlimited/oversized.
- Тело можно не передавать (`{}`) — эквивалент `onlyUnlimited:false`.
**Возвращает:**
```json
{ "success": true, "data": {
"chain": "ETH", "owner": "0x9dB8...",
"total": 2,
"revoked": [
{ "token": "USDT", "spender": "Relay Router v2",
"tokenAddress": "0xdAC1...", "spenderAddress": "0xb92f...",
"txid": "0x...", "previousAllowance": "1157920..." }
],
"failed": [],
"skipped": []
}}
```
Если открытых дверей нет:
```json
{ "success": true, "data": { "total": 0, "revoked": [], "failed": [], "skipped": [],
"message": "no open approvals — nothing to revoke" } }
```
**Примечание:**
- Выполняется **последовательно** (по 1 транзакции на дверь, ждёт подтверждения каждой) — может занять
минуту-две. Покажи лоадер.
- При нехватке газа: отзывает сколько успел → остальное помечает `skipped` («пополни нативку и повтори»).
Повторный вызов безопасен (уже закрытые двери скан не покажет, газ не тратится).
- `total` — сколько открытых дверей нашлось; разбивка по `revoked` / `failed` / `skipped`.
---
# 4. Типичные флоу (end-to-end)
**Стейк ETH:**
1. `POST /wallets/ETH/stake/quote` → показать комиссию + ожидаемый stETH + APR.
2. По «Подтвердить» → `POST /wallets/ETH/stake` (с `Idempotency-Key`).
3. `GET /wallets/ETH/stake/positions` → обновить баланс stETH.
**LP-депозит:**
1. `GET /wallets/ETH/lp/pools` → таблица пулов (APR/TVL).
2. Юзер выбрал пул, ввёл диапазон + суммы → `POST /wallets/ETH/lp/quote`
показать комиссию (`appFeeTotal*`) и баннер при `balance.sufficient === false`.
3. Если хватает → `POST /wallets/ETH/lp/add` (с `Idempotency-Key`).
4. `GET /wallets/ETH/lp/positions` → список позиций; `remove` по кнопке.
**Защита от слива (отвязка):**
1. `GET /wallets/ETH/approvals` (и/или `BSC`) → если есть `unlimited` — предупредить.
2. `POST /wallets/ETH/approvals/revoke-all` → закрыть все двери одной кнопкой.
3. Повторный `GET /approvals` → список пуст ✅.
---
# 5. Коды ошибок
| HTTP | code | Что значит / что делать |
|---|---|---|
| 401 | — | Нет/невалидный JWT. |
| 403 | — | JWT валиден, но нет кошелька для chain, либо CSRF не прошёл. |
| 400 | — | Невалидный body (chain вне whitelist, amount не число, requestId чужой, token/spender не адрес и т.п.). |
| 400 | `INSUFFICIENT_BALANCE` | Не хватает токена/нативки (LP add при нехватке второй ноги — падает до списаний). |
| 404 | — | Нет кошелька / mnemonic не найден / endpoint вне whitelist. |
| 409 | — | Конфликт Idempotency-Key (тот же ключ с другим телом). |
| 429 | — | Rate limit. |
| 502 | — | Upstream (RPC / внешний API) или broadcast не прошёл. |
| 503 | — | Сервис аудита недоступен (мутация не выполнена — безопасно повторить). |
---
# Примечание: вспомогательный endpoint стоимости (опционально)
Если нужно показать комиссию 0.7% (в токене + native + USD) и проверку баланса **до** стейка/свопа в
едином виде — есть read-only `POST /wallets/{chain}/op-cost` (ETH/BSC/SOL/TRX):
**Вход:** `{ "token": "USDT", "amountHuman": "10" }` (или `amount` в smallest units; пусто/нативный
символ = нативная монета).
**Возвращает:** `appCommission { inToken, inNative, usd }` + `balance { token, gas }` + `sufficient`
(всё обычными числами). В LP-секторе эта информация уже встроена в `/lp/quote` (см. 2.2).

161
dist/assets/index-5UhazDjJ.js vendored Normal file

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

4
dist/index.html vendored
View File

@@ -5,8 +5,8 @@
<link rel="icon" type="image/svg+xml" href="/favicon.svg" /> <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>ЭКСА — Ваш мост в мир цифровых активов</title> <title>ЭКСА — Ваш мост в мир цифровых активов</title>
<script type="module" crossorigin src="/assets/index-D__qMdwn.js"></script> <script type="module" crossorigin src="/assets/index-5UhazDjJ.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-CfMiYyAp.css"> <link rel="stylesheet" crossorigin href="/assets/index-DDkfqY41.css">
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>

View File

@@ -18,6 +18,7 @@ import { PolitikaCookiePage } from '@pages/politika-cookie'
import { SoglasiePage } from '@pages/soglasie-personalnyh-dannyh' import { SoglasiePage } from '@pages/soglasie-personalnyh-dannyh'
import { ReestryPage } from '@pages/reestr-pd-rkn' import { ReestryPage } from '@pages/reestr-pd-rkn'
import { TransactionsPage } from '@pages/transactions' import { TransactionsPage } from '@pages/transactions'
import { StakingPage } from '@pages/staking'
import { WalletLayout } from '@widgets/wallet-layout' import { WalletLayout } from '@widgets/wallet-layout'
import { ROUTES } from '@shared/config/routes' import { ROUTES } from '@shared/config/routes'
import { ScrollToTop } from './ScrollToTop' import { ScrollToTop } from './ScrollToTop'
@@ -53,6 +54,7 @@ export function RouterProvider() {
<Route path={ROUTES.SWAP} element={<SwapPage />} /> <Route path={ROUTES.SWAP} element={<SwapPage />} />
<Route path={ROUTES.BRIDGE} element={<BridgePage />} /> <Route path={ROUTES.BRIDGE} element={<BridgePage />} />
<Route path={ROUTES.TRANSACTIONS} element={<TransactionsPage />} /> <Route path={ROUTES.TRANSACTIONS} element={<TransactionsPage />} />
<Route path={ROUTES.STAKING} element={<StakingPage />} />
</Route> </Route>
<Route path={ROUTES.WALLET} element={<WalletPage />} /> <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', SEED_PHRASE: '/seed-phrase',
CONVERTER: '/converter', CONVERTER: '/converter',
KYC: '/kyc', KYC: '/kyc',
STAKING: '/staking',
RESTORE_PASSWORD: '/restore-password', RESTORE_PASSWORD: '/restore-password',
PUBLICHNAYA_OFERTA: '/publichnaya-oferta', PUBLICHNAYA_OFERTA: '/publichnaya-oferta',
POLITIKA_PERSONALNYH_DANNYH: '/politika-personalnyh-dannyh', 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 to={ROUTES.WALLET} className={styles.dropdownItem} onClick={() => setOpen(false)}>
Кошелёк Кошелёк
</Link> </Link>
<Link to={ROUTES.STAKING} className={styles.dropdownItem} onClick={() => setOpen(false)}>
Стейкинг
</Link>
<Link to={ROUTES.TRANSACTIONS} className={styles.dropdownItem} onClick={() => setOpen(false)}> <Link to={ROUTES.TRANSACTIONS} className={styles.dropdownItem} onClick={() => setOpen(false)}>
Транзакции Транзакции
</Link> </Link>

File diff suppressed because one or more lines are too long