Files
frontend/CryptoWallet-DeFi-Guide.md
2026-06-17 17:54:30 +03:00

389 lines
19 KiB
Markdown
Raw Permalink 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) комиссии нет.
---
# 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).