389 lines
19 KiB
Markdown
389 lines
19 KiB
Markdown
# 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).
|