26 KiB
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):
{ "amount": "100000000000000000" }
amount— сумма в smallest units (ETH = wei 10^18; SOL = lamports 10^9), строка, > 0.
Возвращает (ETH):
{ "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):
{ "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:
{ "amount": "100000000000000000" }
amount— то же, что в quote (smallest units).
Возвращает (ETH):
{ "success": true, "data": {
"feeTxid": "0x...", "stakeTxid": "0x...",
"stakeAmountWei": "99300000000000000", "stakeAmountWeiHuman": "0.0993",
"appFeeWei": "700000000000000", "appFeeWeiHuman": "0.0007"
}}
Возвращает (SOL):
{ "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):
{ "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):
{ "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:
{ "amount": "50000000000000000", "mode": "swap" }
amount— сумма stETH в wei.mode—"swap"(мгновенно, дефолт) или"queue"(очередь Lido).
SOL:
{ "stakeAccount": "<pubkey stake-аккаунта>" }
stakeAccount— адрес из позиции (1.3) или из ответа stake (1.2).
Возвращает (ETH, mode=swap):
{ "success": true, "data": {
"mode": "swap", "approveTxid": "0x...", "swapTxid": "0x...",
"swapAmountStEthWei": "50000000000000000", "swapAmountStEthWeiHuman": "0.05",
"minEthOutWei": "49800000000000000", "minEthOutWeiHuman": "0.0498"
}}
Возвращает (ETH, mode=queue):
{ "success": true, "data": {
"mode": "queue", "approveTxid": "0x...", "requestTxid": "0x...",
"requestedStEthWei": "50000000000000000", "requestedStEthWeiHuman": "0.05",
"note": "requestId появится в /stake/positions; затем /unstake/claim когда isFinalized"
}}
Возвращает (SOL):
{ "success": true, "data": {
"action": "deactivate",
"txid": "<signature>",
"note": "Деактивация запущена. Через 1-2 эпохи вызови /unstake снова — выведет средства."
}}
либо после cooldown:
{ "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:
{ "requestId": "12345" }
requestId— id заявки из/stake/positions.withdrawalRequests, у которойisFinalized:true.
Возвращает:
{ "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) и текущей ценой.
Вход: нет тела.
Возвращает:
{ "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):
{
"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).
Возвращает:
{ "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?).
Возвращает:
{ "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:
{ "tokenId": "123456", "percent": 100 }
tokenId— id позиции из/lp/positions.percent— опционально, (0..100], дефолт 100.
Возвращает:
{ "success": true, "data": {
"removeTxid": "0x...",
"removedLiquidity": "354877171283",
"burned": true
}}
Примечание: сервер проверяет, что tokenId принадлежит кошельку юзера (ownerOf), иначе 400.
2.5. GET /wallets/ETH/lp/positions
Что делает: читает LP-позиции юзера (NFT) в курируемых пулах прямо из блокчейна: диапазон, текущая стоимость по каждому токену, накопленные комиссии, в диапазоне ли цена.
Вход: нет тела.
Возвращает:
{ "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 в пути.
Возвращает:
{ "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:
{
"token": "0xdAC17F958D2ee523a2206206994597C13D831ec7",
"spender": "0xb92fe925dc43a0ecde6c8b1a2709c170ec4fff4f"
}
token— адрес ERC20 (полеtokenAddressиз скана).spender— адрес спендера (полеspenderAddressиз скана).
Возвращает:
{ "success": true, "data": {
"chain": "ETH", "token": "0xdAC1...", "spender": "0xb92f...",
"txid": "0x...", "previousAllowance": "1157920..."
}}
либо, если уже было 0:
{ "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 (опционально):
{ "onlyUnlimited": false }
onlyUnlimited—false(дефолт) = отозвать ВСЕ ненулевые;true= только unlimited/oversized.- Тело можно не передавать (
{}) — эквивалентonlyUnlimited:false.
Возвращает:
{ "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": []
}}
Если открытых дверей нет:
{ "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:
POST /wallets/ETH/stake/quote→ показать комиссию + ожидаемый stETH + APR.- По «Подтвердить» →
POST /wallets/ETH/stake(сIdempotency-Key). GET /wallets/ETH/stake/positions→ обновить баланс stETH.
LP-депозит:
GET /wallets/ETH/lp/pools→ таблица пулов (APR/TVL).- Юзер выбрал пул, ввёл диапазон + суммы →
POST /wallets/ETH/lp/quote→ показать комиссию (appFeeTotal*) и баннер приbalance.sufficient === false. - Если хватает →
POST /wallets/ETH/lp/add(сIdempotency-Key). GET /wallets/ETH/lp/positions→ список позиций;removeпо кнопке.
Защита от слива (отвязка):
GET /wallets/ETH/approvals(и/илиBSC) → если естьunlimited— предупредить.POST /wallets/ETH/approvals/revoke-all→ закрыть все двери одной кнопкой.- Повторный
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).