Files
frontend/CryptoWallet-DeFi-Guide.md
2026-06-15 23:06:24 +03:00

591 lines
26 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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).