# CryptoWallet DeFi API — гайд для фронтендера Сектор DeFi: **Стейкинг** · **Пулы (Uniswap v3 LP)** · **Отвязка кошельков (approvals)** Базовый URL: `https://app.cryptowallet.elcsa.ru/api` --- ## Общие правила (читать один раз) **Авторизация:** все запросы требуют `Authorization: Bearer `. **CSRF (для всех `POST`):** double-submit — - cookie `csrf_token=` (сервер выдаёт) - header `X-CSRF-Token: <тот же value>` **Idempotency-Key (для мутирующих `POST`: stake/unstake/claim/lp add/remove/revoke/revoke-all):** заголовок `Idempotency-Key: `. Сгенерь 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": "", "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: ` + 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": "", "stakeAccount": "", "stakeLamports": "993000000", "stakeLamportsHuman": "0.993", "appFeeLamports": "7000000", "appFeeLamportsHuman": "0.007", "validator": "" }} ``` **Примечание:** `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": "", "positions": [ { "stakeAccount": "", "lamports": "993000000", "lamportsHuman": "0.993", "state": "active", "validator": "", "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: ` + body: ETH: ```json { "amount": "50000000000000000", "mode": "swap" } ``` - `amount` — сумма stETH в wei. - `mode` — `"swap"` (мгновенно, дефолт) или `"queue"` (очередь Lido). SOL: ```json { "stakeAccount": "" } ``` - `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": "", "note": "Деактивация запущена. Через 1-2 эпохи вызови /unstake снова — выведет средства." }} ``` либо после cooldown: ```json { "success": true, "data": { "action": "withdraw", "txid": "", "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: ` + 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: ` + 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: ` + 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: ` + 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: ` + 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).