add staking page
This commit is contained in:
590
CryptoWallet-DeFi-Guide.md
Normal file
590
CryptoWallet-DeFi-Guide.md
Normal 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
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
161
dist/assets/index-D__qMdwn.js
vendored
161
dist/assets/index-D__qMdwn.js
vendored
File diff suppressed because one or more lines are too long
4
dist/index.html
vendored
4
dist/index.html
vendored
@@ -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>
|
||||||
|
|||||||
@@ -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 />} />
|
||||||
|
|||||||
271
src/features/staking/api/stakingApi.ts
Normal file
271
src/features/staking/api/stakingApi.ts
Normal 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 'Произошла ошибка. Попробуйте ещё раз.'
|
||||||
|
}
|
||||||
12
src/features/staking/index.ts
Normal file
12
src/features/staking/index.ts
Normal 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'
|
||||||
76
src/features/staking/model/useStaking.ts
Normal file
76
src/features/staking/model/useStaking.ts
Normal 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'] }),
|
||||||
|
})
|
||||||
|
}
|
||||||
1
src/pages/staking/index.ts
Normal file
1
src/pages/staking/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export { StakingPage } from './ui/StakingPage'
|
||||||
34
src/pages/staking/ui/StakingPage.module.css
Normal file
34
src/pages/staking/ui/StakingPage.module.css
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
14
src/pages/staking/ui/StakingPage.tsx
Normal file
14
src/pages/staking/ui/StakingPage.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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',
|
||||||
|
|||||||
1
src/widgets/staking/index.ts
Normal file
1
src/widgets/staking/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export { StakingWidget } from './ui/StakingWidget'
|
||||||
19
src/widgets/staking/model/meta.ts
Normal file
19
src/widgets/staking/model/meta.ts
Normal 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' },
|
||||||
|
}
|
||||||
42
src/widgets/staking/ui/ChainToggle.module.css
Normal file
42
src/widgets/staking/ui/ChainToggle.module.css
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
24
src/widgets/staking/ui/ChainToggle.tsx
Normal file
24
src/widgets/staking/ui/ChainToggle.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
168
src/widgets/staking/ui/PositionsPanel.module.css
Normal file
168
src/widgets/staking/ui/PositionsPanel.module.css
Normal 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;
|
||||||
|
}
|
||||||
164
src/widgets/staking/ui/PositionsPanel.tsx
Normal file
164
src/widgets/staking/ui/PositionsPanel.tsx
Normal 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}>Разблокируется через 1–2 эпохи</div>}
|
||||||
|
{p.state === 'activating' && <div className={styles.note}>Активируется…</div>}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
171
src/widgets/staking/ui/StakeCard.module.css
Normal file
171
src/widgets/staking/ui/StakeCard.module.css
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
162
src/widgets/staking/ui/StakeCard.tsx
Normal file
162
src/widgets/staking/ui/StakeCard.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
45
src/widgets/staking/ui/StakeQuotePanel.module.css
Normal file
45
src/widgets/staking/ui/StakeQuotePanel.module.css
Normal 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);
|
||||||
|
}
|
||||||
25
src/widgets/staking/ui/StakeQuotePanel.tsx
Normal file
25
src/widgets/staking/ui/StakeQuotePanel.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
18
src/widgets/staking/ui/StakingWidget.module.css
Normal file
18
src/widgets/staking/ui/StakingWidget.module.css
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
139
src/widgets/staking/ui/StakingWidget.tsx
Normal file
139
src/widgets/staking/ui/StakingWidget.tsx
Normal 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 ? 'Средства выведены на кошелёк' : 'Деактивация запущена — вывод через 1–2 эпохи',
|
||||||
|
})
|
||||||
|
},
|
||||||
|
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>
|
||||||
|
)
|
||||||
|
}
|
||||||
156
src/widgets/staking/ui/UnstakeModal.module.css
Normal file
156
src/widgets/staking/ui/UnstakeModal.module.css
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
96
src/widgets/staking/ui/UnstakeModal.tsx
Normal file
96
src/widgets/staking/ui/UnstakeModal.tsx
Normal 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}>
|
||||||
|
Средства разблокируются через 1–2 эпохи. После завершения вернитесь и подтвердите вывод.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{showNotice && <WithdrawNotice />}
|
||||||
|
|
||||||
|
<button
|
||||||
|
className={styles.confirmBtn}
|
||||||
|
onClick={() => onConfirm(chain === 'ETH' ? mode : undefined)}
|
||||||
|
disabled={pending}
|
||||||
|
>
|
||||||
|
{confirmLabel}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
32
src/widgets/staking/ui/WithdrawNotice.module.css
Normal file
32
src/widgets/staking/ui/WithdrawNotice.module.css
Normal 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;
|
||||||
|
}
|
||||||
14
src/widgets/staking/ui/WithdrawNotice.tsx
Normal file
14
src/widgets/staking/ui/WithdrawNotice.tsx
Normal 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 недель.</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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
Reference in New Issue
Block a user