From f8403eb9f67b69caa9b7e11d0cd18c9b8dc0a606 Mon Sep 17 00:00:00 2001 From: ZOMBIIIIIII <120676065+Metaton241@users.noreply.github.com> Date: Thu, 11 Jun 2026 12:03:51 +0300 Subject: [PATCH] initalll --- .env.example | 13 + apps/api/src/app.ts | 20 + apps/api/src/config/env.ts | 24 + .../src/controllers/approvals.controller.ts | 405 ++++++++++ apps/api/src/controllers/lp.controller.ts | 195 +++++ .../api/src/controllers/staking.controller.ts | 294 +++++++ apps/api/src/controllers/wallet.controller.ts | 184 ++++- apps/api/src/lib/defi-contracts.ts | 137 ++++ apps/api/src/lib/evm-tx-policy.ts | 10 + apps/api/src/lib/sol-validator.ts | 132 ++++ apps/api/src/lib/uniswap-subgraph.ts | 160 ++++ apps/api/src/lib/uniswap-v3-math.ts | 133 ++++ apps/api/src/routes/approvals.routes.ts | 12 + apps/api/src/routes/lp.routes.ts | 13 + apps/api/src/routes/staking.routes.ts | 20 + apps/api/src/routes/wallet.routes.ts | 2 + apps/api/src/services/sol-stake.service.ts | 331 ++++++++ apps/api/src/services/staking.service.ts | 419 ++++++++++ .../src/services/swap-orchestrator.service.ts | 9 +- apps/api/src/services/uniswap-lp.service.ts | 748 ++++++++++++++++++ apps/api/src/services/wallet-signer-bridge.ts | 54 ++ .../api/src/services/wallet-signer.service.ts | 65 ++ apps/api/swagger.json | 624 +++++++++++++++ 23 files changed, 3998 insertions(+), 6 deletions(-) create mode 100644 apps/api/src/controllers/approvals.controller.ts create mode 100644 apps/api/src/controllers/lp.controller.ts create mode 100644 apps/api/src/controllers/staking.controller.ts create mode 100644 apps/api/src/lib/defi-contracts.ts create mode 100644 apps/api/src/lib/sol-validator.ts create mode 100644 apps/api/src/lib/uniswap-subgraph.ts create mode 100644 apps/api/src/lib/uniswap-v3-math.ts create mode 100644 apps/api/src/routes/approvals.routes.ts create mode 100644 apps/api/src/routes/lp.routes.ts create mode 100644 apps/api/src/routes/staking.routes.ts create mode 100644 apps/api/src/services/sol-stake.service.ts create mode 100644 apps/api/src/services/staking.service.ts create mode 100644 apps/api/src/services/uniswap-lp.service.ts diff --git a/.env.example b/.env.example index ce94d61..0f55fa3 100644 --- a/.env.example +++ b/.env.example @@ -64,3 +64,16 @@ DB_PORT=5432 DB_USER= DB_PASSWORD= DB_NAME= + +# ── DeFi: staking (Lido/Solana) + Uniswap v3 LP ──────────────────── +# Адреса опциональны (mainnet-дефолты в config/env.ts). +LIDO_STETH_ADDRESS= +LIDO_WITHDRAWAL_QUEUE_ADDRESS= +CURVE_STETH_POOL_ADDRESS= +UNISWAP_V3_NFPM_ADDRESS= +# Курируемые v3-пулы (csv). Дефолт — 3 пула: WETH/USDT, WBTC/WETH, XAUt/USDT. +UNISWAP_LP_POOLS= +# The Graph API-ключ для Uniswap v3 subgraph APR/TVL (пусто → APR=null). +THEGRAPH_API_KEY= +# Vote-адрес валидатора для нативного Solana-стейкинга. ОПЦИОНАЛЬНО (пусто → авто-выбор). +SOL_STAKE_VALIDATOR_VOTE= diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts index ac77eb2..e880f89 100644 --- a/apps/api/src/app.ts +++ b/apps/api/src/app.ts @@ -18,6 +18,9 @@ import btcProxyRoutes from './routes/btc-proxy.routes'; import pricesRoutes from './routes/prices.routes'; import tokensRoutes from './routes/tokens.routes'; import bridgeRoutes from './routes/bridge.routes'; +import stakingRoutes from './routes/staking.routes'; +import lpRoutes from './routes/lp.routes'; +import approvalsRoutes from './routes/approvals.routes'; const app = express(); @@ -101,9 +104,26 @@ const protect = [authMiddleware, csrfMiddleware]; app.use('/api/wallets/create', ...protect, sensitiveLimiter); app.use('/api/wallets/mnemonic/reveal', ...protect, mnemonicRevealLimiter); app.use('/api/wallets/:chain/send', ...protect, sensitiveLimiter); +// Sensitive money-flow — строжайший лимит ТОЛЬКО на execute-эндпоинты (точный путь через app.post). +// Read-only quote/positions/pools матчатся обычным mutateLimiter (через router mount ниже). +app.post('/api/wallets/:chain/stake', ...protect, sensitiveLimiter); +app.post('/api/wallets/:chain/unstake', ...protect, sensitiveLimiter); +app.post('/api/wallets/:chain/unstake/claim', ...protect, sensitiveLimiter); +app.post('/api/wallets/:chain/lp/add', ...protect, sensitiveLimiter); +app.post('/api/wallets/:chain/lp/remove', ...protect, sensitiveLimiter); +// Custodial revoke approval (approve(spender,0)) — money-safety op, строжайший лимит. +app.post('/api/wallets/:chain/approvals/revoke-all', ...protect, sensitiveLimiter); +app.post('/api/wallets/:chain/approvals/revoke', ...protect, sensitiveLimiter); // Mutating (proxy + read endpoints) — повышенный лимит app.use('/api/wallets', ...protect, mutateLimiter, walletRoutes); +// Staking (Lido ETH) — custodial. Тот же namespace; пути /:chain/stake* не пересекаются с walletRoutes. +app.use('/api/wallets', ...protect, mutateLimiter, stakingRoutes); +// Uniswap v3 LP — custodial. Пути /:chain/lp/* не пересекаются с wallet/staking routes. +app.use('/api/wallets', ...protect, mutateLimiter, lpRoutes); +// Approval scan/revoke — custodial. Пути /:chain/approvals* не пересекаются с wallet/staking/lp routes. +// Закрывает повисший unlimited-approval (drain-вектор) + read-only scan открытых дверей. +app.use('/api/wallets', ...protect, mutateLimiter, approvalsRoutes); app.use('/api/relay', ...protect, mutateLimiter, relayProxyRoutes); // Jumper.xyz — LiFi-backed bridge aggregator (50+ chains: ETH/BSC/SOL/TRX/BTC + others). // Используется когда Relay не поддерживает направление (TRX/BTC bridges). diff --git a/apps/api/src/config/env.ts b/apps/api/src/config/env.ts index 414d6ae..176a9d5 100644 --- a/apps/api/src/config/env.ts +++ b/apps/api/src/config/env.ts @@ -71,6 +71,30 @@ export let env = { jupiterFeeBps: parseInt(p.JUPITER_FEE_BPS || '70'), etherscanApiKey: p.ETHERSCAN_API_KEY || null, bscscanApiKey: p.BSCSCAN_API_KEY || null, + // ── DeFi (staking + Uniswap v3 LP) ────────────────────────────────── + // Адреса хранятся lowercase; при использовании прогоняются через ethers.getAddress() + // → корректный checksum без риска ручной опечатки. Mainnet defaults. + defi: { + // Lido stETH (submit payable + ERC20 balanceOf) + lidoStEth: (p.LIDO_STETH_ADDRESS || '0xae7ab96520de3a18e5e111b5eaab095312d7fe84').toLowerCase(), + // Lido Withdrawal Queue (requestWithdrawals / claimWithdrawal) — для mode=queue + lidoWithdrawalQueue: (p.LIDO_WITHDRAWAL_QUEUE_ADDRESS || '0x889edc2edab5f40e902b864ad4d7abe3ce53ada8').toLowerCase(), + // Curve stETH/ETH pool — для мгновенного вывода (mode=swap) stETH→ETH (deepest liquidity) + curveStEthPool: (p.CURVE_STETH_POOL_ADDRESS || '0xdc24316b9ae028f1497c275eb9192a3ea0f67022').toLowerCase(), + // Uniswap v3 NonfungiblePositionManager (mint/increase/decrease/collect/burn) + uniswapV3Nfpm: (p.UNISWAP_V3_NFPM_ADDRESS || '0xc36442b4a4522e871399cd717abdd847ab11fe88').toLowerCase(), + // Курируемые v3-пулы (csv lowercased). Default — 3 пула (адреса подтверждены on-chain): + // WETH/USDT 0.3% 0x4e68ccd3e89f51c3074ca5072bbac773960dfa36 + // WBTC/WETH 0.3% 0xcbcdf9626bc03e24f779434178a73a0b4bad62ed + // XAUt/USDT 0.05% 0x6546055f46e866a4b9a4a13e81273e3152bae5da + uniswapLpPools: (p.UNISWAP_LP_POOLS || '0x4e68ccd3e89f51c3074ca5072bbac773960dfa36,0xcbcdf9626bc03e24f779434178a73a0b4bad62ed,0x6546055f46e866a4b9a4a13e81273e3152bae5da') + .split(',').map((s) => s.trim().toLowerCase()).filter(Boolean), + // The Graph API-ключ для Uniswap v3 subgraph (APR/TVL). null → APR недоступен (не блокирует операции) + theGraphApiKey: p.THEGRAPH_API_KEY || null, + // Vote-адрес валидатора для нативного Solana-стейкинга. ОПЦИОНАЛЬНО: пусто → авто-выбор + // хорошего валидатора (Stakewiz → RPC → fallback, см. lib/sol-validator.ts). Задай, чтобы закрепить. + solStakeValidatorVote: p.SOL_STAKE_VALIDATOR_VOTE || '', + }, }; let vaultToken: string | null = null; diff --git a/apps/api/src/controllers/approvals.controller.ts b/apps/api/src/controllers/approvals.controller.ts new file mode 100644 index 0000000..266ae08 --- /dev/null +++ b/apps/api/src/controllers/approvals.controller.ts @@ -0,0 +1,405 @@ +/** + * Approval management — custodial scan + revoke + revoke-all («отвязка от всего») для EVM (ETH/BSC). + * + * Зачем: депозиты «утекают» через повисший unlimited-approval (напр. USDT → Relay Router v2 = + * MAX_UINT256): любой Relay-солвер может вывести каждое новое поступление токена с кошелька, + * заполняя свой ордер. Кошелёк custodial (ключ на сервере), поэтому отзыв `approve(spender, 0)` + * должен подписать сервер — revoke.cash не подходит (у пользователя нет приватного ключа). + * + * Безопасность: + * - revoke ВСЕГДА хардкодит `amount: '0'` → endpoint может ТОЛЬКО снизить allowance, никогда + * не выдать. Поэтому даже произвольные (но валидные) token/spender не являются attack surface. + * - JWT-bind кошелька chain + Idempotency-Key + auditLogStrict + decrypt mnemonic в памяти — + * тот же мутационный паттерн, что в bridge.routes.ts / staking.controller.ts. + * - scan — read-only `allowance` view-call (без подписи), только для видимости открытых дверей. + * + * Prevention НОВЫХ unlimited-approval сделана отдельно (lib/evm-tx-policy.ts cap 2^200); + * этот модуль закрывает УЖЕ повисшие approval-ы и даёт инструмент на будущее. + */ + +import { Request, Response } from 'express'; +import { ethers } from 'ethers'; +import { logger } from '../lib/logger'; +import { WalletModel } from '../models/wallet.model'; +import { UserModel } from '../models/user.model'; +import { decryptMnemonic } from '../services/crypto.service'; +import { auditLog, auditLogStrict, completeAudit } from '../lib/audit-log'; +import { claimIdempotency, saveIdempotencyResponse, extractIdempotencyKey } from '../lib/idempotency'; +import { signAndBroadcastEvmApprove, readErc20Allowance } from '../services/wallet-signer-bridge'; +import { getEvmTokens } from '../lib/token-registry'; +import { WETH_ADDRESS } from '../lib/defi-contracts'; +import { formatSmallestUnits } from '../lib/amount-units'; +import { env } from '../config/env'; + +type EvmChain = 'ETH' | 'BSC'; + +/** + * Порог «бесконечного» allowance — совпадает с cap из evm-tx-policy.ts (2^200 ≫ любого реального + * supply токена, но ловит MAX_UINT256 и near-max сентинелы). allowance выше → флаг unlimited. + */ +const UNLIMITED_THRESHOLD = 2n ** 200n; + +interface KnownSpender { name: string; address: string; } +interface KnownToken { symbol: string; address: string; decimals: number; } + +/** + * Known спендеры, которым наш flow когда-либо выдаёт approve. Сканируем allowance кошелька против + * них, чтобы показать открытые двери. Relay-роутеры — из evm-tx-policy.ts (RELAY_ROUTERS); + * Permit2 — canonical (один адрес на всех chain); NFPM — из env (Uniswap v3 LP). + */ +function knownSpenders(chain: EvmChain): KnownSpender[] { + const common: KnownSpender[] = [ + { name: 'Relay Router v2', address: '0xb92fe925dc43a0ecde6c8b1a2709c170ec4fff4f' }, + { name: 'Relay Depository v1', address: '0xa5f565650890fba1824ee0f21ebbbf660a179934' }, + { name: 'Permit2', address: '0x000000000022d473030f116ddee9f6b43ac78ba3' }, + ]; + if (chain === 'ETH') { + return [ + ...common, + { name: 'Relay Router v1', address: '0xf70da97812cb96acdf810712aa562db8dfa3dbef' }, + { name: 'Uniswap v3 NFPM', address: env.defi.uniswapV3Nfpm }, + ]; + } + return common; +} + +/** Known токены, которые реально аппрувятся в нашем flow (из registry) + WETH (авто-wrap для LP). */ +function knownTokens(chain: EvmChain): KnownToken[] { + const base: KnownToken[] = getEvmTokens(chain).map((t) => ({ + symbol: t.symbol, + address: t.contractAddress, + decimals: t.decimals, + })); + if (chain === 'ETH') base.push({ symbol: 'WETH', address: WETH_ADDRESS, decimals: 18 }); + return base; +} + +function getUserId(req: Request): string | null { + return (req as any).auth?.userId || null; +} + +/** ETH/BSC only. Возвращает нормализованный chain или null (+ ответ 400). */ +function resolveEvmChain(req: Request, res: Response): EvmChain | null { + const chain = String(req.params.chain).toUpperCase(); + if (chain === 'ETH' || chain === 'BSC') return chain; + res.status(400).json({ success: false, error: 'Approvals supported on ETH and BSC only' }); + return null; +} + +/** Одна открытая дверь (ненулевой allowance), найденная сканом. */ +interface OpenApproval { + token: string; + tokenAddress: string; + spender: string; + spenderAddress: string; + allowance: string; + allowanceHuman: string; + unlimited: boolean; +} + +/** + * Сканирует allowance кошелька по known-токенам × known-спендерам и возвращает только ОТКРЫТЫЕ + * (ненулевые) двери. Read-only (view-call, без подписи). Переиспользуется в `scanApprovals` (GET) и + * `revokeAllApprovals` (POST) — единый источник «какие двери открыты». + */ +async function scanOpenApprovals(chain: EvmChain, owner: string): Promise { + const tokens = knownTokens(chain); + const spenders = knownSpenders(chain); + const pairs: Array<{ token: KnownToken; spender: KnownSpender }> = []; + for (const token of tokens) for (const spender of spenders) pairs.push({ token, spender }); + + const results = await Promise.all(pairs.map(async ({ token, spender }) => { + try { + const allowance = await readErc20Allowance({ + chain, token: token.address, owner, spender: spender.address, + }); + if (allowance === 0n) return null; // только открытые двери + const unlimited = allowance > UNLIMITED_THRESHOLD; + return { + token: token.symbol, + tokenAddress: token.address, + spender: spender.name, + spenderAddress: spender.address, + allowance: allowance.toString(), + allowanceHuman: unlimited ? 'unlimited' : formatSmallestUnits(allowance.toString(), token.decimals), + unlimited, + } as OpenApproval; + } catch (err: any) { + logger.warn(`approvals scan: allowance read failed token=${token.symbol} spender=${spender.name}: ${err?.message}`); + return null; + } + })); + + return results.filter((r): r is OpenApproval => r !== null); +} + +// ── GET /:chain/approvals — read-only scan открытых allowance ────────────── +export async function scanApprovals(req: Request, res: Response): Promise { + const userId = getUserId(req); + if (!userId) { res.status(401).json({ success: false, error: 'auth required' }); return; } + const chain = resolveEvmChain(req, res); + if (!chain) return; + + const wallet = await WalletModel.findByUserAndChain(userId, chain); + if (!wallet) { + res.status(403).json({ success: false, error: `No ${chain} wallet for user — create wallet first` }); + return; + } + + const approvals = await scanOpenApprovals(chain, wallet.address); + res.json({ success: true, data: { chain, owner: wallet.address, approvals } }); +} + +// ── POST /:chain/approvals/revoke — custodial revoke (approve(spender, 0)) ── +export async function revokeApproval(req: Request, res: Response): Promise { + const userId = getUserId(req); + if (!userId) { res.status(401).json({ success: false, error: 'auth required' }); return; } + const chain = resolveEvmChain(req, res); + if (!chain) return; + + const token = String(req.body?.token || ''); + const spender = String(req.body?.spender || ''); + if (!ethers.utils.isAddress(token)) { + res.status(400).json({ success: false, error: 'token must be a valid EVM contract address' }); + return; + } + if (!ethers.utils.isAddress(spender)) { + res.status(400).json({ success: false, error: 'spender must be a valid EVM address' }); + return; + } + + const wallet = await WalletModel.findByUserAndChain(userId, chain); + if (!wallet) { + res.status(403).json({ success: false, error: `No ${chain} wallet for user — create wallet first` }); + return; + } + + // Pre-check: если allowance уже 0 — не тратим газ, отвечаем сразу (read-only, без подписи). + let current: bigint | null = null; + try { + current = await readErc20Allowance({ chain, token, owner: wallet.address, spender }); + } catch (err: any) { + logger.warn(`approvals revoke pre-check failed (proceeding): ${err?.message}`); + } + if (current === 0n) { + res.json({ + success: true, + data: { chain, token, spender, txid: null, alreadyZero: true, message: 'Allowance already 0 — nothing to revoke' }, + }); + return; + } + + // ── Idempotency claim ── + const idempKey = extractIdempotencyKey(req.headers['idempotency-key']); + if (idempKey) { + try { + const claim = await claimIdempotency(userId, idempKey, req.body || {}); + if (!claim.fresh && claim.cached) { + res.status(claim.cached.status).type('application/json').send(claim.cached.body); + return; + } + } catch (err: any) { + res.status(409).json({ success: false, error: err.message }); + return; + } + } + + // ── Audit row BEFORE broadcast (strict) ── + let auditId: string; + try { + auditId = await auditLogStrict({ event: 'approval.revoke', userId, ip: req.ip || null, meta: { chain, token, spender } }); + } catch (auditErr: any) { + logger.error(`Audit INSERT must succeed for approval.revoke: ${auditErr.message}`); + res.status(503).json({ success: false, error: 'Audit service unavailable' }); + return; + } + + // ── Decrypt mnemonic ── + const blob = await UserModel.getEncryptedMnemonic(userId); + if (!blob) { + await completeAudit(auditId, 'failure', undefined, 'NO_MNEMONIC'); + res.status(404).json({ success: false, error: 'Encrypted mnemonic not found' }); + return; + } + let mnemonic: string; + try { + mnemonic = decryptMnemonic(blob); + } catch { + await completeAudit(auditId, 'failure', undefined, 'DECRYPT_FAILED'); + res.status(500).json({ success: false, error: 'Mnemonic decrypt failed' }); + return; + } + + try { + // amount '0' ЗАХАРДКОЖЕН — endpoint может только отозвать, не выдать approval. + const { txid } = await signAndBroadcastEvmApprove({ + chain, + mnemonic, + expectedFromAddress: wallet.address, + spender, + token, + amount: '0', + }); + auditLog({ + event: 'approval.revoke.broadcast', userId, ip: req.ip || null, result: 'success', + meta: { chain, token, spender, txid }, + }).catch(() => {}); + + await completeAudit(auditId, 'success'); + const data = { chain, token, spender, txid, previousAllowance: current?.toString() ?? null }; + const respBody = { success: true, data }; + if (idempKey) { + try { await saveIdempotencyResponse(userId, idempKey, 200, JSON.stringify(respBody)); } catch { /* ignore */ } + } + res.status(200).json(respBody); + } catch (err: any) { + await completeAudit(auditId, 'failure', undefined, err?.code || err?.message?.slice(0, 80)); + logger.warn(`Approval revoke failed: chain=${chain} token=${token} spender=${spender} → ${err?.message}`); + const respBody = { success: false, error: err?.message || 'revoke failed', code: err?.code }; + if (idempKey) { + try { await saveIdempotencyResponse(userId, idempKey, 502, JSON.stringify(respBody)); } catch { /* ignore */ } + } + res.status(502).json(respBody); + } finally { + mnemonic = ''; + } +} + +/** Похоже ли на «не хватает газа/нативки» — тогда дальнейшие revoke тоже упадут, прерываемся рано. */ +function isInsufficientFunds(err: any): boolean { + const code = String(err?.code || ''); + const msg = String(err?.message || '').toLowerCase(); + return code === 'INSUFFICIENT_FUNDS' || msg.includes('insufficient funds'); +} + +// ── POST /:chain/approvals/revoke-all — «отвязка от всего»: отозвать ВСЕ открытые двери ── +export async function revokeAllApprovals(req: Request, res: Response): Promise { + const userId = getUserId(req); + if (!userId) { res.status(401).json({ success: false, error: 'auth required' }); return; } + const chain = resolveEvmChain(req, res); + if (!chain) return; + + // По умолчанию отзываем ВСЕ ненулевые approval («от всего»). onlyUnlimited:true — только unlimited. + const onlyUnlimited = req.body?.onlyUnlimited === true; + + const wallet = await WalletModel.findByUserAndChain(userId, chain); + if (!wallet) { + res.status(403).json({ success: false, error: `No ${chain} wallet for user — create wallet first` }); + return; + } + + // ── Серверный скан (не доверяем клиентскому списку) → какие двери открыты ── + let open: OpenApproval[]; + try { + open = await scanOpenApprovals(chain, wallet.address); + } catch (err: any) { + res.status(502).json({ success: false, error: `approvals scan failed: ${err?.message || 'unknown'}` }); + return; + } + const targets = onlyUnlimited ? open.filter((o) => o.unlimited) : open; + + // Нет открытых дверей → ничего не подписываем (без decrypt/tx/газа). + if (targets.length === 0) { + res.json({ + success: true, + data: { chain, owner: wallet.address, revoked: [], failed: [], skipped: [], total: 0, message: 'no open approvals — nothing to revoke' }, + }); + return; + } + + // ── Idempotency claim ── + const idempKey = extractIdempotencyKey(req.headers['idempotency-key']); + if (idempKey) { + try { + const claim = await claimIdempotency(userId, idempKey, req.body || {}); + if (!claim.fresh && claim.cached) { + res.status(claim.cached.status).type('application/json').send(claim.cached.body); + return; + } + } catch (err: any) { + res.status(409).json({ success: false, error: err.message }); + return; + } + } + + // ── Audit BEFORE broadcast (strict) ── + let auditId: string; + try { + auditId = await auditLogStrict({ + event: 'approval.revoke-all', userId, ip: req.ip || null, + meta: { chain, onlyUnlimited, count: targets.length }, + }); + } catch (auditErr: any) { + logger.error(`Audit INSERT must succeed for approval.revoke-all: ${auditErr.message}`); + res.status(503).json({ success: false, error: 'Audit service unavailable' }); + return; + } + + // ── Decrypt mnemonic ОДИН раз для всех revoke ── + const blob = await UserModel.getEncryptedMnemonic(userId); + if (!blob) { + await completeAudit(auditId, 'failure', undefined, 'NO_MNEMONIC'); + res.status(404).json({ success: false, error: 'Encrypted mnemonic not found' }); + return; + } + let mnemonic: string; + try { + mnemonic = decryptMnemonic(blob); + } catch { + await completeAudit(auditId, 'failure', undefined, 'DECRYPT_FAILED'); + res.status(500).json({ success: false, error: 'Mnemonic decrypt failed' }); + return; + } + + const revoked: Array<{ token: string; spender: string; tokenAddress: string; spenderAddress: string; txid: string; previousAllowance: string }> = []; + const failed: Array<{ token: string; spender: string; tokenAddress: string; spenderAddress: string; error: string }> = []; + const skipped: Array<{ token: string; spender: string; tokenAddress: string; spenderAddress: string; reason: string }> = []; + + try { + // ПОСЛЕДОВАТЕЛЬНО: signAndBroadcastEvmApprove ждёт 1 conf → следующий nonce корректен. + for (let i = 0; i < targets.length; i++) { + const t = targets[i]; + try { + // amount '0' ЗАХАРДКОЖЕН — отзыв, не выдача. + const { txid } = await signAndBroadcastEvmApprove({ + chain, mnemonic, expectedFromAddress: wallet.address, + spender: t.spenderAddress, token: t.tokenAddress, amount: '0', + }); + revoked.push({ + token: t.token, spender: t.spender, tokenAddress: t.tokenAddress, spenderAddress: t.spenderAddress, + txid, previousAllowance: t.allowance, + }); + } catch (err: any) { + // Кончился газ/нативка → остальные тоже упадут: помечаем остаток skipped и выходим. + if (isInsufficientFunds(err)) { + failed.push({ token: t.token, spender: t.spender, tokenAddress: t.tokenAddress, spenderAddress: t.spenderAddress, error: 'insufficient funds for gas' }); + for (let j = i + 1; j < targets.length; j++) { + const s = targets[j]; + skipped.push({ token: s.token, spender: s.spender, tokenAddress: s.tokenAddress, spenderAddress: s.spenderAddress, reason: 'stopped: insufficient gas — top up native and retry' }); + } + break; + } + failed.push({ token: t.token, spender: t.spender, tokenAddress: t.tokenAddress, spenderAddress: t.spenderAddress, error: err?.message?.slice(0, 160) || 'revoke failed' }); + } + } + + auditLog({ + event: 'approval.revoke-all.broadcast', userId, ip: req.ip || null, result: 'success', + meta: { chain, revoked: revoked.length, failed: failed.length, skipped: skipped.length }, + }).catch(() => {}); + await completeAudit(auditId, 'success'); + + const data = { chain, owner: wallet.address, revoked, failed, skipped, total: targets.length }; + const respBody = { success: true, data }; + if (idempKey) { + try { await saveIdempotencyResponse(userId, idempKey, 200, JSON.stringify(respBody)); } catch { /* ignore */ } + } + res.status(200).json(respBody); + } catch (err: any) { + await completeAudit(auditId, 'failure', undefined, err?.code || err?.message?.slice(0, 80)); + logger.warn(`Approval revoke-all fatal: chain=${chain} → ${err?.message}`); + res.status(502).json({ success: false, error: err?.message || 'revoke-all failed' }); + } finally { + mnemonic = ''; + } +} diff --git a/apps/api/src/controllers/lp.controller.ts b/apps/api/src/controllers/lp.controller.ts new file mode 100644 index 0000000..c24cd0b --- /dev/null +++ b/apps/api/src/controllers/lp.controller.ts @@ -0,0 +1,195 @@ +/** + * Uniswap v3 LP endpoints — custodial (ETH). Пути /:chain/lp/*. + * Безопасность: JWT-bind ETH-кошелька, Idempotency-Key, auditLogStrict, decrypt mnemonic в памяти. + */ + +import { Request, Response } from 'express'; +import { logger } from '../lib/logger'; +import { WalletModel } from '../models/wallet.model'; +import { UserModel } from '../models/user.model'; +import { decryptMnemonic } from '../services/crypto.service'; +import { auditLogStrict, completeAudit } from '../lib/audit-log'; +import { claimIdempotency, saveIdempotencyResponse, extractIdempotencyKey } from '../lib/idempotency'; +import { StakingError } from '../services/staking.service'; +import { + getPools, + quoteAdd, + executeAddLiquidity, + executeRemoveLiquidity, + readLpPositions, +} from '../services/uniswap-lp.service'; + +function getUserId(req: Request): string | null { + return (req as any).auth?.userId || null; +} +function assertEth(req: Request, res: Response): boolean { + if (String(req.params.chain).toUpperCase() !== 'ETH') { + res.status(400).json({ success: false, error: 'Uniswap v3 LP supported on ETH only' }); + return false; + } + return true; +} +function validAmount(v: unknown): v is string { + return typeof v === 'string' && /^\d+$/.test(v); +} + +interface Ctx { auditId: string; mnemonic: string; wallet: { address: string }; idempKey: string | null; zero: () => void; } + +async function beginMutation(req: Request, res: Response, userId: string, event: string, meta: Record): Promise { + const wallet = await WalletModel.findByUserAndChain(userId, 'ETH'); + if (!wallet) { res.status(403).json({ success: false, error: 'No ETH wallet for user — create wallet first' }); return null; } + + const idempKey = extractIdempotencyKey(req.headers['idempotency-key']); + if (idempKey) { + try { + const claim = await claimIdempotency(userId, idempKey, req.body || {}); + if (!claim.fresh && claim.cached) { res.status(claim.cached.status).type('application/json').send(claim.cached.body); return null; } + } catch (err: any) { res.status(409).json({ success: false, error: err.message }); return null; } + } + + let auditId: string; + try { + auditId = await auditLogStrict({ event, userId, ip: req.ip || null, meta }); + } catch (auditErr: any) { + logger.error(`Audit INSERT must succeed for ${event}: ${auditErr.message}`); + res.status(503).json({ success: false, error: 'Audit service unavailable' }); + return null; + } + + const blob = await UserModel.getEncryptedMnemonic(userId); + if (!blob) { await completeAudit(auditId, 'failure', undefined, 'NO_MNEMONIC'); res.status(404).json({ success: false, error: 'Encrypted mnemonic not found' }); return null; } + let mnemonic: string; + try { mnemonic = decryptMnemonic(blob); } catch { await completeAudit(auditId, 'failure', undefined, 'DECRYPT_FAILED'); res.status(500).json({ success: false, error: 'Mnemonic decrypt failed' }); return null; } + + const ctx: Ctx = { auditId, mnemonic, wallet, idempKey, zero: () => { ctx.mnemonic = ''; } }; + return ctx; +} + +async function finish(res: Response, userId: string, idempKey: string | null, auditId: string, data: unknown): Promise { + await completeAudit(auditId, 'success'); + const body = { success: true, data }; + if (idempKey) { try { await saveIdempotencyResponse(userId, idempKey, 200, JSON.stringify(body)); } catch { /* ignore */ } } + res.status(200).json(body); +} +async function fail(res: Response, userId: string, idempKey: string | null, auditId: string, err: any, label: string): Promise { + const code = err instanceof StakingError ? err.httpStatus : 502; + await completeAudit(auditId, 'failure', undefined, err?.code || err?.message?.slice(0, 80)); + logger.warn(`LP ${label} failed: ${err?.message}`); + const body = { success: false, error: err?.message || `${label} failed`, code: err?.code }; + if (idempKey) { try { await saveIdempotencyResponse(userId, idempKey, code, JSON.stringify(body)); } catch { /* ignore */ } } + res.status(code).json(body); +} + +// ── GET /:chain/lp/pools ─────────────────────────────────────────────── +export async function lpPools(req: Request, res: Response): Promise { + if (!getUserId(req)) { res.status(401).json({ success: false, error: 'auth required' }); return; } + if (!assertEth(req, res)) return; + try { + res.json({ success: true, data: await getPools() }); + } catch (err: any) { + res.status(502).json({ success: false, error: err?.message || 'pools read failed' }); + } +} + +// ── POST /:chain/lp/quote ────────────────────────────────────────────── +export async function lpQuote(req: Request, res: Response): Promise { + if (!getUserId(req)) { res.status(401).json({ success: false, error: 'auth required' }); return; } + if (!assertEth(req, res)) return; + const userId = getUserId(req)!; + const b = req.body || {}; + if (!b.pool || !validAmount(String(b.amount0Desired ?? '')) || !validAmount(String(b.amount1Desired ?? ''))) { + res.status(400).json({ success: false, error: 'pool, amount0Desired, amount1Desired (integer strings) required' }); + return; + } + try { + // Кошелёк опционален: если есть — добавим проверку баланса token0/token1/gas (read-only, без 403). + const wallet = await WalletModel.findByUserAndChain(userId, 'ETH'); + const quote = await quoteAdd({ + poolAddress: String(b.pool), + priceLower: Number(b.priceLower), + priceUpper: Number(b.priceUpper), + amount0Desired: String(b.amount0Desired), + amount1Desired: String(b.amount1Desired), + slippageBps: b.slippageBps !== undefined ? Number(b.slippageBps) : undefined, + ownerAddress: wallet?.address, + useNativeEth: b.useNativeEth === true || b.useNativeEth === 'true', + }); + res.json({ success: true, data: quote }); + } catch (err: any) { + const status = err instanceof StakingError ? err.httpStatus : 502; + res.status(status).json({ success: false, error: err?.message || 'quote failed', code: err?.code }); + } +} + +// ── POST /:chain/lp/add ──────────────────────────────────────────────── +export async function lpAdd(req: Request, res: Response): Promise { + const userId = getUserId(req); + if (!userId) { res.status(401).json({ success: false, error: 'auth required' }); return; } + if (!assertEth(req, res)) return; + const b = req.body || {}; + if (!b.pool || !validAmount(String(b.amount0Desired ?? '')) || !validAmount(String(b.amount1Desired ?? ''))) { + res.status(400).json({ success: false, error: 'pool, amount0Desired, amount1Desired (integer strings) required' }); + return; + } + + const ctx = await beginMutation(req, res, userId, 'lp.add', { pool: String(b.pool) }); + if (!ctx) return; + const { auditId, mnemonic, wallet, idempKey } = ctx; + try { + const result = await executeAddLiquidity({ + mnemonic, expectedFromAddress: wallet.address, + poolAddress: String(b.pool), + priceLower: Number(b.priceLower), + priceUpper: Number(b.priceUpper), + amount0Desired: String(b.amount0Desired), + amount1Desired: String(b.amount1Desired), + slippageBps: b.slippageBps !== undefined ? Number(b.slippageBps) : undefined, + useNativeEth: b.useNativeEth === true || b.useNativeEth === 'true', + }); + await finish(res, userId, idempKey, auditId, result); + } catch (err: any) { + await fail(res, userId, idempKey, auditId, err, 'add'); + } finally { + ctx.zero(); + } +} + +// ── POST /:chain/lp/remove ───────────────────────────────────────────── +export async function lpRemove(req: Request, res: Response): Promise { + const userId = getUserId(req); + if (!userId) { res.status(401).json({ success: false, error: 'auth required' }); return; } + if (!assertEth(req, res)) return; + const b = req.body || {}; + const tokenId = String(b.tokenId ?? ''); + const percent = Number(b.percent ?? 100); + if (!/^\d+$/.test(tokenId)) { + res.status(400).json({ success: false, error: 'tokenId (integer string) required' }); + return; + } + + const ctx = await beginMutation(req, res, userId, 'lp.remove', { tokenId, percent }); + if (!ctx) return; + const { auditId, mnemonic, wallet, idempKey } = ctx; + try { + const result = await executeRemoveLiquidity({ mnemonic, expectedFromAddress: wallet.address, tokenId, percent }); + await finish(res, userId, idempKey, auditId, result); + } catch (err: any) { + await fail(res, userId, idempKey, auditId, err, 'remove'); + } finally { + ctx.zero(); + } +} + +// ── GET /:chain/lp/positions ─────────────────────────────────────────── +export async function lpPositions(req: Request, res: Response): Promise { + const userId = getUserId(req); + if (!userId) { res.status(401).json({ success: false, error: 'auth required' }); return; } + if (!assertEth(req, res)) return; + const wallet = await WalletModel.findByUserAndChain(userId, 'ETH'); + if (!wallet) { res.status(403).json({ success: false, error: 'No ETH wallet for user — create wallet first' }); return; } + try { + res.json({ success: true, data: await readLpPositions(wallet.address) }); + } catch (err: any) { + res.status(502).json({ success: false, error: err?.message || 'positions read failed' }); + } +} diff --git a/apps/api/src/controllers/staking.controller.ts b/apps/api/src/controllers/staking.controller.ts new file mode 100644 index 0000000..eacfa1c --- /dev/null +++ b/apps/api/src/controllers/staking.controller.ts @@ -0,0 +1,294 @@ +/** + * Staking endpoints — custodial. ETH → Lido, SOL → native (StakeProgram). + * + * Безопасность как в bridge.routes.ts: JWT-bind (адрес ≡ кошелёк юзера для chain), + * Idempotency-Key (анти-дабл-спенд), auditLogStrict до broadcast, decrypt mnemonic в памяти. + * Quote/positions читают из чейна (без записи). Пути: /:chain/stake*, /:chain/unstake*. + */ + +import { Request, Response } from 'express'; +import { logger } from '../lib/logger'; +import { WalletModel } from '../models/wallet.model'; +import { UserModel } from '../models/user.model'; +import { decryptMnemonic } from '../services/crypto.service'; +import { auditLog, auditLogStrict, completeAudit } from '../lib/audit-log'; +import { claimIdempotency, saveIdempotencyResponse, extractIdempotencyKey } from '../lib/idempotency'; +import { + quoteLidoStake, + executeLidoStake, + readLidoPositions, + unstakeLidoViaSwap, + unstakeLidoViaQueue, + claimLidoWithdrawal, + StakingError, +} from '../services/staking.service'; +import { + quoteSolStake, + executeSolStake, + executeSolUnstake, + readSolStakePositions, +} from '../services/sol-stake.service'; + +type StakeChain = 'ETH' | 'SOL'; + +function getUserId(req: Request): string | null { + return (req as any).auth?.userId || null; +} + +/** Разрешённые chains для стейкинга. Возвращает нормализованный chain или null (+ ответ 400). */ +function resolveStakeChain(req: Request, res: Response): StakeChain | null { + const chain = String(req.params.chain).toUpperCase(); + if (chain === 'ETH' || chain === 'SOL') return chain; + res.status(400).json({ success: false, error: 'Staking supported on ETH (Lido) and SOL (native) only' }); + return null; +} + +function validAmount(v: unknown): v is string { + return typeof v === 'string' && /^\d+$/.test(v) && v !== '0'; +} + +interface MutationCtx { + auditId: string; + mnemonic: string; + wallet: { address: string }; + idempKey: string | null; + zero: () => void; +} + +/** + * Общая преамбула мутации: JWT-bind кошелька chain + Idempotency-Key + auditLogStrict + + * decrypt mnemonic. На любой неуспех сам отвечает в res и возвращает null. + */ +async function beginMutation( + req: Request, + res: Response, + userId: string, + chain: StakeChain, + event: string, + meta: Record, +): Promise { + const wallet = await WalletModel.findByUserAndChain(userId, chain); + if (!wallet) { + res.status(403).json({ success: false, error: `No ${chain} wallet for user — create wallet first` }); + return null; + } + + const idempKey = extractIdempotencyKey(req.headers['idempotency-key']); + if (idempKey) { + try { + const claim = await claimIdempotency(userId, idempKey, req.body || {}); + if (!claim.fresh && claim.cached) { + res.status(claim.cached.status).type('application/json').send(claim.cached.body); + return null; + } + } catch (err: any) { + res.status(409).json({ success: false, error: err.message }); + return null; + } + } + + let auditId: string; + try { + auditId = await auditLogStrict({ event, userId, ip: req.ip || null, meta }); + } catch (auditErr: any) { + logger.error(`Audit INSERT must succeed for ${event}: ${auditErr.message}`); + res.status(503).json({ success: false, error: 'Audit service unavailable' }); + return null; + } + + const blob = await UserModel.getEncryptedMnemonic(userId); + if (!blob) { + await completeAudit(auditId, 'failure', undefined, 'NO_MNEMONIC'); + res.status(404).json({ success: false, error: 'Encrypted mnemonic not found' }); + return null; + } + let mnemonic: string; + try { + mnemonic = decryptMnemonic(blob); + } catch { + await completeAudit(auditId, 'failure', undefined, 'DECRYPT_FAILED'); + res.status(500).json({ success: false, error: 'Mnemonic decrypt failed' }); + return null; + } + + const ctx: MutationCtx = { auditId, mnemonic, wallet, idempKey, zero: () => { ctx.mnemonic = ''; } }; + return ctx; +} + +async function finishMutation( + res: Response, userId: string, idempKey: string | null, auditId: string, data: unknown, +): Promise { + await completeAudit(auditId, 'success'); + const respBody = { success: true, data }; + if (idempKey) { + try { await saveIdempotencyResponse(userId, idempKey, 200, JSON.stringify(respBody)); } catch { /* ignore */ } + } + res.status(200).json(respBody); +} + +async function failMutation( + res: Response, userId: string, idempKey: string | null, auditId: string, err: any, label: string, +): Promise { + const code = err instanceof StakingError ? err.httpStatus : 502; + await completeAudit(auditId, 'failure', undefined, err?.code || err?.message?.slice(0, 80)); + logger.warn(`Stake ${label} failed: ${err?.message}`); + const respBody = { success: false, error: err?.message || `${label} failed`, code: err?.code }; + if (idempKey) { + try { await saveIdempotencyResponse(userId, idempKey, code, JSON.stringify(respBody)); } catch { /* ignore */ } + } + res.status(code).json(respBody); +} + +// ── POST /:chain/stake/quote ─────────────────────────────────────────── +export async function stakeQuote(req: Request, res: Response): Promise { + const userId = getUserId(req); + if (!userId) { res.status(401).json({ success: false, error: 'auth required' }); return; } + const chain = resolveStakeChain(req, res); + if (!chain) return; + + const amount = String(req.body?.amount || ''); + if (!validAmount(amount)) { + res.status(400).json({ success: false, error: 'amount must be a positive integer string (ETH=wei, SOL=lamports)' }); + return; + } + try { + const quote = chain === 'ETH' ? await quoteLidoStake(amount) : await quoteSolStake(amount); + res.json({ success: true, data: quote }); + } catch (err: any) { + const status = err instanceof StakingError ? err.httpStatus : 502; + res.status(status).json({ success: false, error: err?.message || 'quote failed', code: err?.code }); + } +} + +// ── POST /:chain/stake ───────────────────────────────────────────────── +export async function stakeExecute(req: Request, res: Response): Promise { + const userId = getUserId(req); + if (!userId) { res.status(401).json({ success: false, error: 'auth required' }); return; } + const chain = resolveStakeChain(req, res); + if (!chain) return; + + const amount = String(req.body?.amount || ''); + if (!validAmount(amount)) { + res.status(400).json({ success: false, error: 'amount must be a positive integer string (ETH=wei, SOL=lamports)' }); + return; + } + + const ctx = await beginMutation(req, res, userId, chain, 'stake.execute', { chain, amount }); + if (!ctx) return; + const { auditId, mnemonic, wallet, idempKey } = ctx; + try { + const result = chain === 'ETH' + ? await executeLidoStake({ mnemonic, expectedFromAddress: wallet.address, amountWei: amount }) + : await executeSolStake({ mnemonic, expectedFromAddress: wallet.address, amountLamports: amount }); + auditLog({ event: 'stake.broadcast', userId, ip: req.ip || null, result: 'success', meta: { chain } }).catch(() => {}); + await finishMutation(res, userId, idempKey, auditId, result); + } catch (err: any) { + await failMutation(res, userId, idempKey, auditId, err, 'stake'); + } finally { + ctx.zero(); + } +} + +// ── POST /:chain/unstake ─────────────────────────────────────────────── +export async function unstakeExecute(req: Request, res: Response): Promise { + const userId = getUserId(req); + if (!userId) { res.status(401).json({ success: false, error: 'auth required' }); return; } + const chain = resolveStakeChain(req, res); + if (!chain) return; + + // Валидация тела зависит от chain + let amount = ''; + let mode = 'swap'; + let stakeAccount = ''; + if (chain === 'ETH') { + amount = String(req.body?.amount || ''); + if (!validAmount(amount)) { + res.status(400).json({ success: false, error: 'amount must be a positive integer string (stETH wei)' }); + return; + } + mode = String(req.body?.mode || 'swap'); + if (mode !== 'swap' && mode !== 'queue') { + res.status(400).json({ success: false, error: 'mode must be "swap" (default, instant) or "queue"' }); + return; + } + // slippage на выводе задаётся сервером автоматически (маленький фикс) — клиент не управляет. + } else { + stakeAccount = String(req.body?.stakeAccount || ''); + if (!stakeAccount) { + res.status(400).json({ success: false, error: 'stakeAccount required (SOL stake account address)' }); + return; + } + } + + const meta = chain === 'ETH' ? { chain, mode, amount } : { chain, stakeAccount }; + const ctx = await beginMutation(req, res, userId, chain, 'unstake.execute', meta); + if (!ctx) return; + const { auditId, mnemonic, wallet, idempKey } = ctx; + try { + let result: unknown; + if (chain === 'ETH') { + result = mode === 'queue' + ? await unstakeLidoViaQueue({ mnemonic, expectedFromAddress: wallet.address, amountStEthWei: amount }) + : await unstakeLidoViaSwap({ mnemonic, expectedFromAddress: wallet.address, amountStEthWei: amount }); + } else { + result = await executeSolUnstake({ mnemonic, expectedFromAddress: wallet.address, stakeAccount }); + } + await finishMutation(res, userId, idempKey, auditId, result); + } catch (err: any) { + await failMutation(res, userId, idempKey, auditId, err, 'unstake'); + } finally { + ctx.zero(); + } +} + +// ── POST /:chain/unstake/claim (ETH/Lido queue only) ─────────────────── +export async function unstakeClaim(req: Request, res: Response): Promise { + const userId = getUserId(req); + if (!userId) { res.status(401).json({ success: false, error: 'auth required' }); return; } + const chain = resolveStakeChain(req, res); + if (!chain) return; + if (chain !== 'ETH') { + res.status(400).json({ success: false, error: 'claim only applies to ETH Lido queue withdrawals' }); + return; + } + + const requestId = String(req.body?.requestId || ''); + if (!/^\d+$/.test(requestId)) { + res.status(400).json({ success: false, error: 'requestId must be a positive integer string' }); + return; + } + + const ctx = await beginMutation(req, res, userId, 'ETH', 'unstake.lido.claim', { requestId }); + if (!ctx) return; + const { auditId, mnemonic, wallet, idempKey } = ctx; + try { + const result = await claimLidoWithdrawal({ mnemonic, expectedFromAddress: wallet.address, requestId }); + await finishMutation(res, userId, idempKey, auditId, result); + } catch (err: any) { + await failMutation(res, userId, idempKey, auditId, err, 'claim'); + } finally { + ctx.zero(); + } +} + +// ── GET /:chain/stake/positions ──────────────────────────────────────── +export async function stakePositions(req: Request, res: Response): Promise { + const userId = getUserId(req); + if (!userId) { res.status(401).json({ success: false, error: 'auth required' }); return; } + const chain = resolveStakeChain(req, res); + if (!chain) return; + + const wallet = await WalletModel.findByUserAndChain(userId, chain); + if (!wallet) { + res.status(403).json({ success: false, error: `No ${chain} wallet for user — create wallet first` }); + return; + } + try { + const positions = chain === 'ETH' + ? await readLidoPositions(wallet.address) + : await readSolStakePositions(wallet.address); + res.json({ success: true, data: positions }); + } catch (err: any) { + res.status(502).json({ success: false, error: err?.message || 'positions read failed' }); + } +} diff --git a/apps/api/src/controllers/wallet.controller.ts b/apps/api/src/controllers/wallet.controller.ts index bb21c55..26e47e9 100644 --- a/apps/api/src/controllers/wallet.controller.ts +++ b/apps/api/src/controllers/wallet.controller.ts @@ -16,14 +16,24 @@ import { import { saveQuote, getQuote, deleteQuote, QUOTE_TTL_SECONDS, type CachedSwapQuote } from '../lib/swap-quote-cache'; import { generateUlid } from '../utils/ulid'; import { getPricesBySymbols } from '../services/price-oracle.service'; -import { SOL_TOKENS } from '../lib/token-registry'; +import { SOL_TOKENS, NATIVE_SYMBOLS, getTokenInfo } from '../lib/token-registry'; import { resolveAmountFromBody, resolveSendDecimals, resolveSwapDecimalsBscTrx, resolveSwapDecimalsSol, formatSmallestUnits, + NATIVE_DECIMALS, } from '../lib/amount-units'; +import { computeAppFee } from '../lib/app-fee'; +import { + readErc20Balance, + readEvmNativeBalance, + readSolBalance, + readSplTokenBalance, + readTrxBalance, + readTrc20Balance, +} from '../services/wallet-signer-bridge'; const SOL_WRAPPED_NATIVE_MINT = 'So11111111111111111111111111111111111111112'; function solMintToSymbol(mint: string): string | null { @@ -42,6 +52,18 @@ import { logger } from '../lib/logger'; const ALLOWED_CHAINS = new Set(ALL_CHAINS); const MAX_TX_LIMIT = 100; +/** USD round to 6 знаков (для отображения комиссии). */ +function roundUsd(n: number): number { + return Math.round(n * 1e6) / 1e6; +} +/** Number → human-строка без scientific notation, без хвостовых нулей (для мелких native-сумм). */ +function trimNum(n: number): string { + if (!Number.isFinite(n)) return '0'; + let s = n.toFixed(12).replace(/0+$/, '').replace(/\.$/, ''); + if (s === '' || s === '-0') s = '0'; + return s; +} + /** * Per-chain cost-estimate для /send endpoint'а. * @@ -1445,6 +1467,166 @@ export const WalletController = { } }, + /** + * POST /api/wallets/:chain/op-cost + * + * Read-only «стоимость операции» для свапа/бриджа. Возвращает (всё в ОБЫЧНЫХ числах, не decimals): + * - нашу 0.7%-комиссию в самом токене + эквивалент в native-монете сети + в USD; + * - проверку баланса: хватает ли входного токена на сумму операции И нативной монеты на газ. + * Сетевую/gas-комиссию отдаёт существующий swap/cost-estimate / relay quote — здесь не дублируем. + * + * Без mnemonic / broadcast / БД. JWT-bound (только кошелёк юзера). Цены — whitelist (getCoingeckoId). + * Body: { token?: "USDT" | "" (=native), amount? | amountHuman }. Chains: ETH | BSC | SOL | TRX. + */ + async opCost(req: Request, res: Response) { + const userId = req.auth!.userId; + const chain = String(req.params.chain).toUpperCase() as ChainCode; + if (chain !== 'ETH' && chain !== 'BSC' && chain !== 'SOL' && chain !== 'TRX') { + res.status(400).json({ success: false, error: 'op-cost supported on ETH, BSC, SOL, TRX' }); + return; + } + + const nativeSymbol = NATIVE_SYMBOLS[chain]; + const nativeDecimals = NATIVE_DECIMALS[chain]; + + // ── token resolution (пусто или native-символ → нативная монета) ── + const tokenRaw = req.body?.token; + let isNative = false; + let tokenSymbol: string; + let tokenDecimals: number; + let tokenAddress: string | null = null; + if ( + tokenRaw === undefined || tokenRaw === null || tokenRaw === '' || + (typeof tokenRaw === 'string' && tokenRaw.toUpperCase() === nativeSymbol) + ) { + isNative = true; + tokenSymbol = nativeSymbol; + tokenDecimals = nativeDecimals; + } else { + if (typeof tokenRaw !== 'string' || !/^[A-Za-z0-9]{1,12}$/.test(tokenRaw)) { + res.status(400).json({ success: false, error: 'Invalid token symbol format' }); + return; + } + const info = getTokenInfo(chain, tokenRaw); + if (!info) { + res.status(400).json({ success: false, error: `Unknown token "${tokenRaw}" on ${chain}` }); + return; + } + tokenSymbol = tokenRaw.toUpperCase(); + tokenDecimals = info.decimals; + tokenAddress = info.address; + } + + // ── amount → smallest units ── + let amountSmallest: string; + try { + amountSmallest = resolveAmountFromBody({ amount: req.body?.amount, amountHuman: req.body?.amountHuman }, tokenDecimals); + } catch (err: any) { + res.status(400).json({ success: false, error: err.message }); + return; + } + if (!isValidAmount(amountSmallest)) { + res.status(400).json({ success: false, error: 'Invalid amount' }); + return; + } + + // ── wallet (JWT-bound — только свой кошелёк) ── + const wallet = await WalletModel.findByUserAndChain(userId, chain); + if (!wallet) { + res.status(404).json({ success: false, error: `No ${chain} wallet for user` }); + return; + } + const addr = wallet.address; + + // ── наша app-комиссия 0.7% (в токене) ── + const feeSmallest = computeAppFee(amountSmallest); + const feeTokenHuman = formatSmallestUnits(feeSmallest.toString(), tokenDecimals); + const amountHumanStr = formatSmallestUnits(amountSmallest, tokenDecimals); + + // Цены (whitelist через getCoingeckoId) — опционально, без них баланс-проверка всё равно работает. + let tokenPrice: number | null = null; + let nativePrice: number | null = null; + try { + const priceMap = await getPricesBySymbols([ + { chain, symbol: tokenSymbol }, + { chain, symbol: nativeSymbol }, + ]); + tokenPrice = priceMap.get(`${chain}:${tokenSymbol}`) ?? null; + nativePrice = priceMap.get(`${chain}:${nativeSymbol}`) ?? null; + } catch { /* prices optional */ } + + const feeUsd = tokenPrice != null ? Number(feeTokenHuman) * tokenPrice : null; + const feeNativeHuman = (feeUsd != null && nativePrice != null && nativePrice > 0) + ? trimNum(feeUsd / nativePrice) + : null; + + // ── балансы (on-chain read, без подписи) ── + const reserveSmallest = chain === 'TRX' ? 5_000_000n // ~5 TRX + : chain === 'SOL' ? 1_000_000n // ~0.001 SOL + : 1_000_000_000_000_000n; // 0.001 ETH/BNB + let nativeBal: bigint | null = null; + let tokenBal: bigint | null = null; + try { + nativeBal = (chain === 'ETH' || chain === 'BSC') ? await readEvmNativeBalance(chain, addr) + : chain === 'SOL' ? await readSolBalance(addr) + : await readTrxBalance(addr); + } catch (err: any) { + logger.warn(`op-cost native balance read failed ${chain}: ${err?.message}`); + } + try { + if (isNative) { + tokenBal = nativeBal; + } else if (chain === 'ETH' || chain === 'BSC') { + tokenBal = await readErc20Balance(chain, tokenAddress!, addr); + } else if (chain === 'SOL') { + tokenBal = await readSplTokenBalance(addr, tokenAddress!); + } else { + tokenBal = await readTrc20Balance(tokenAddress!, addr); + } + } catch (err: any) { + logger.warn(`op-cost token balance read failed ${chain} ${tokenSymbol}: ${err?.message}`); + } + + const need = BigInt(amountSmallest); + // token-нога + let tokenSufficient: boolean | null = null; + let tokenHaveHuman: string | null = null; + let tokenShortfallHuman = '0'; + if (tokenBal != null) { + tokenHaveHuman = formatSmallestUnits(tokenBal.toString(), tokenDecimals); + tokenSufficient = tokenBal >= need; + if (!tokenSufficient) tokenShortfallHuman = formatSmallestUnits((need - tokenBal).toString(), tokenDecimals); + } + // gas-нога: если вход = native → нужно amount + резерв; иначе только резерв на газ + let gasSufficient: boolean | null = null; + let gasHaveHuman: string | null = null; + if (nativeBal != null) { + gasHaveHuman = formatSmallestUnits(nativeBal.toString(), nativeDecimals); + const gasNeed = isNative ? need + reserveSmallest : reserveSmallest; + gasSufficient = nativeBal >= gasNeed; + } + const reserveHuman = formatSmallestUnits(reserveSmallest.toString(), nativeDecimals); + + res.json({ + success: true, + data: { + chain, + input: { token: tokenSymbol, amount: amountHumanStr }, + appCommission: { + ratePercent: 0.7, + inToken: { symbol: tokenSymbol, amount: feeTokenHuman }, + inNative: { symbol: nativeSymbol, amount: feeNativeHuman }, + usd: feeUsd != null ? roundUsd(feeUsd) : null, + }, + balance: { + token: { symbol: tokenSymbol, have: tokenHaveHuman, need: amountHumanStr, sufficient: tokenSufficient, shortfall: tokenShortfallHuman }, + gas: { symbol: nativeSymbol, have: gasHaveHuman, needReserve: reserveHuman, sufficient: gasSufficient }, + }, + sufficient: tokenSufficient === true && gasSufficient === true, + }, + }); + }, + /** * POST /api/wallets/SOL/sign-and-broadcast-tx — custodial sign + broadcast для arbitrary serialized SOL tx. * Используется для Relay bridge SOL-side (когда Relay /execute возвращает unsigned Solana tx). diff --git a/apps/api/src/lib/defi-contracts.ts b/apps/api/src/lib/defi-contracts.ts new file mode 100644 index 0000000..b969ddb --- /dev/null +++ b/apps/api/src/lib/defi-contracts.ts @@ -0,0 +1,137 @@ +/** + * DeFi contract constants + scoped EVM allowlist для нового custodial-функционала + * (Lido staking + вывод, Uniswap v3 LP). + * + * В отличие от `evm-tx-policy.ts` (который защищает КЛИЕНТСКИЙ /sign-raw-evm-tx с произвольным + * calldata), здесь calldata строится НА СЕРВЕРЕ из фиксированных ABI + адресов из env — как в + * bridge-execute.service.ts. Поэтому Relay-политику не трогаем. Этот allowlist — defense-in-depth: + * даже server-built tx обязана иметь `to` из явного множества, иначе подпись отклоняется. + */ + +import { ethers } from 'ethers'; +import { env } from '../config/env'; + +/** ETH mainnet RPC failover-набор (read + provider для server-built tx чтения). */ +export const ETH_RPCS = [ + 'https://ethereum-rpc.publicnode.com', + 'https://eth.llamarpc.com', + 'https://rpc.ankr.com/eth', +]; + +/** Checksummed-адреса (env хранит lowercase, getAddress даёт корректный EIP-55). */ +export function lidoStEthAddress(): string { + return ethers.utils.getAddress(env.defi.lidoStEth); +} +export function lidoWithdrawalQueueAddress(): string { + return ethers.utils.getAddress(env.defi.lidoWithdrawalQueue); +} +export function curveStEthPoolAddress(): string { + return ethers.utils.getAddress(env.defi.curveStEthPool); +} +export function uniswapNfpmAddress(): string { + return ethers.utils.getAddress(env.defi.uniswapV3Nfpm); +} + +/** Курируемые Uniswap v3 пулы (lowercase). */ +export function uniswapLpPools(): string[] { + return env.defi.uniswapLpPools; +} +export function isCuratedPool(poolAddress: string): boolean { + return env.defi.uniswapLpPools.includes(poolAddress.toLowerCase()); +} + +/** + * Множество разрешённых `to`-адресов для server-built DeFi-tx (основные вызовы через + * signAndBroadcastRawEvm). approve идёт через signAndBroadcastEvmApprove (отдельный helper). + * Pool token-адреса (для approve→NFPM) передаются динамически через `extraAllowed`. + */ +function baseAllowed(): Set { + return new Set([ + env.defi.lidoStEth.toLowerCase(), + env.defi.lidoWithdrawalQueue.toLowerCase(), + env.defi.curveStEthPool.toLowerCase(), + env.defi.uniswapV3Nfpm.toLowerCase(), + ]); +} + +/** + * Защита: каждый server-built EVM-таргет ДОЛЖЕН быть в allowlist перед подписью. + * @param to целевой контракт + * @param extraAllowed доп. адреса (напр. token0/token1 пула) + */ +export function assertDefiEvmTarget(to: string, extraAllowed: string[] = []): void { + const allowed = baseAllowed(); + for (const a of extraAllowed) allowed.add(a.toLowerCase()); + if (!allowed.has(to.toLowerCase())) { + throw new Error(`DeFi policy: target ${to} not in allowlist`); + } +} + +// ─── ABIs ────────────────────────────────────────────────────────────── + +/** Lido stETH. */ +export const LIDO_ABI = [ + 'function submit(address _referral) payable returns (uint256)', + 'function balanceOf(address owner) view returns (uint256)', + 'function approve(address spender, uint256 amount) returns (bool)', + 'function getPooledEthByShares(uint256 _sharesAmount) view returns (uint256)', + 'function getSharesByPooledEth(uint256 _ethAmount) view returns (uint256)', +]; + +/** Lido Withdrawal Queue (ERC721). */ +export const LIDO_WQ_ABI = [ + 'function requestWithdrawals(uint256[] _amounts, address _owner) returns (uint256[] requestIds)', + 'function claimWithdrawal(uint256 _requestId)', + 'function getWithdrawalRequests(address _owner) view returns (uint256[] requestsIds)', + 'function getWithdrawalStatus(uint256[] _requestIds) view returns (tuple(uint256 amountOfStETH, uint256 amountOfShares, address owner, uint256 timestamp, bool isFinalized, bool isClaimed)[] statuses)', + 'function MIN_STETH_WITHDRAWAL_AMOUNT() view returns (uint256)', + 'function MAX_STETH_WITHDRAWAL_AMOUNT() view returns (uint256)', +]; + +/** Curve stETH/ETH StableSwap pool. i=0 ETH, i=1 stETH. */ +export const CURVE_STETH_ABI = [ + // НЕ payable: Curve забирает stETH через transferFrom (нужен approve), msg.value не используется. + 'function exchange(int128 i, int128 j, uint256 dx, uint256 min_dy) returns (uint256)', + 'function get_dy(int128 i, int128 j, uint256 dx) view returns (uint256)', +]; + +/** Uniswap v3 NonfungiblePositionManager. */ +export const NFPM_ABI = [ + 'function balanceOf(address owner) view returns (uint256)', + 'function ownerOf(uint256 tokenId) view returns (address)', + 'function tokenOfOwnerByIndex(address owner, uint256 index) view returns (uint256)', + 'function positions(uint256 tokenId) view returns (uint96 nonce, address operator, address token0, address token1, uint24 fee, int24 tickLower, int24 tickUpper, uint128 liquidity, uint256 feeGrowthInside0LastX128, uint256 feeGrowthInside1LastX128, uint128 tokensOwed0, uint128 tokensOwed1)', + 'function mint(tuple(address token0, address token1, uint24 fee, int24 tickLower, int24 tickUpper, uint256 amount0Desired, uint256 amount1Desired, uint256 amount0Min, uint256 amount1Min, address recipient, uint256 deadline) params) payable returns (uint256 tokenId, uint128 liquidity, uint256 amount0, uint256 amount1)', + 'function decreaseLiquidity(tuple(uint256 tokenId, uint128 liquidity, uint256 amount0Min, uint256 amount1Min, uint256 deadline) params) payable returns (uint256 amount0, uint256 amount1)', + 'function collect(tuple(uint256 tokenId, address recipient, uint128 amount0Max, uint128 amount1Max) params) payable returns (uint256 amount0, uint256 amount1)', + 'function burn(uint256 tokenId) payable', + 'function multicall(bytes[] data) payable returns (bytes[] results)', +]; + +/** Uniswap v3 Pool (read-only нужное). */ +export const UNIV3_POOL_ABI = [ + 'function slot0() view returns (uint160 sqrtPriceX96, int24 tick, uint16 observationIndex, uint16 observationCardinality, uint16 observationCardinalityNext, uint8 feeProtocol, bool unlocked)', + 'function liquidity() view returns (uint128)', + 'function token0() view returns (address)', + 'function token1() view returns (address)', + 'function fee() view returns (uint24)', + 'function tickSpacing() view returns (int24)', +]; + +/** Минимальный ERC20. */ +export const ERC20_META_ABI = [ + 'function decimals() view returns (uint8)', + 'function symbol() view returns (string)', + 'function balanceOf(address owner) view returns (uint256)', + 'function allowance(address owner, address spender) view returns (uint256)', +]; + +/** Canonical WETH9 (mainnet) — для авто-wrap нативного ETH в LP-депозите. */ +export const WETH_ADDRESS = '0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2'; +export const WETH_ABI = [ + 'function deposit() payable', + 'function withdraw(uint256 wad)', +]; +export function isWeth(addr: string): boolean { + return typeof addr === 'string' && addr.toLowerCase() === WETH_ADDRESS; +} diff --git a/apps/api/src/lib/evm-tx-policy.ts b/apps/api/src/lib/evm-tx-policy.ts index 49d9c78..9ac290d 100644 --- a/apps/api/src/lib/evm-tx-policy.ts +++ b/apps/api/src/lib/evm-tx-policy.ts @@ -132,6 +132,16 @@ export function applyEvmTxPolicy(p: PolicyParams): { routerName?: string; select if (!isTrusted(spenderHex)) { throw new Error(`Sign-raw policy: approve spender ${spenderHex} not in Relay router allowlist for chainId ${p.chainId}`); } + // ── Anti-unlimited-approval (CRITICAL) ── + // НИКОГДА не подписываем «бесконечный» approval (MAX_UINT256 и near-max сентинелы): открытый + // unlimited-approval позволяет солверу/роутеру вывести ЛЮБЫЕ будущие поступления токена с кошелька. + // amount = второй 32-byte параметр (hex 74..138). Cap 2^200 ≫ любого реального количества токенов + // (макс realistic ~2^128), но ловит unlimited. Клиент обязан аппрувить ТОЧНУЮ сумму свопа. + const approveAmount = ethers.BigNumber.from('0x' + p.data.slice(74, 138)); + const MAX_REASONABLE_APPROVE = ethers.BigNumber.from(2).pow(200); + if (approveAmount.gt(MAX_REASONABLE_APPROVE)) { + throw new Error('Sign-raw policy: unlimited/oversized approval rejected — approve the exact swap amount, not MAX (открытый approval позволил бы вывести будущие поступления).'); + } // `to` (token contract) может быть любым — это разрешённый flow. // value для approve() должен быть 0 (стандартный ERC20 approve не принимает value) if (p.value !== '0' && p.value !== '0x0' && ethers.BigNumber.from(p.value).gt(0)) { diff --git a/apps/api/src/lib/sol-validator.ts b/apps/api/src/lib/sol-validator.ts new file mode 100644 index 0000000..4200276 --- /dev/null +++ b/apps/api/src/lib/sol-validator.ts @@ -0,0 +1,132 @@ +/** + * Авто-выбор «хорошего» Solana-валидатора для нативного стейкинга — чтобы работало из коробки + * без операторской настройки. Каскад с кэшем (KeyDB 24ч): + * 1. env override SOL_STAKE_VALIDATOR_VOTE (оператор может закрепить). + * 2. Stakewiz API (без ключа): фильтр delinquent/commission/skip_rate/uptime, сорт по wiz_score. + * 3. RPC getVoteAccounts: фильтр epochVoteAccount/commission/stake, сорт по (commission, credit growth). + * 4. Hard-coded last-resort (проверенный активный валидатор, 0% комиссия). + * + * Менять валидатор на новых стейках безопасно: stake-аккаунт запоминает свой voter в delegation, + * unstake/withdraw читают аккаунт, а не этот резолвер. + */ + +import { Connection, PublicKey } from '@solana/web3.js'; +import { env } from '../config/env'; +import { getRedis } from '../config/redis'; +import { logger } from './logger'; + +/** Last-resort fallback — Yurbason (0% commission, давний, проверен активным). */ +const FALLBACK_VOTE = '9gANMngbGUmAaLXL1RC3JdiaLjRowJXNbzCTh53ht7mq'; +const CACHE_KEY = 'sol:validator:default'; +const CACHE_TTL_SEC = 24 * 60 * 60; // 24h +const STAKEWIZ_URL = 'https://api.stakewiz.com/validators'; +const HTTP_TIMEOUT_MS = 6000; + +let _conn: Connection | null = null; +function defaultConn(): Connection { + if (!_conn) _conn = new Connection('https://api.mainnet-beta.solana.com', 'confirmed'); + return _conn; +} + +function isValidVote(addr: unknown): addr is string { + if (typeof addr !== 'string' || !addr) return false; + try { new PublicKey(addr); return true; } catch { return false; } +} + +/** + * Резолвит vote-адрес валидатора. Никогда не throws — деградирует до hard-coded fallback. + */ +export async function resolveStakeValidator(conn?: Connection): Promise { + // 1) env override + if (isValidVote(env.defi.solStakeValidatorVote)) { + return env.defi.solStakeValidatorVote; + } + + // cache + try { + const cached = await getRedis().get(CACHE_KEY); + if (cached && isValidVote(cached)) return cached; + } catch { /* ignore */ } + + let chosen: string | null = null; + + // 2) Stakewiz (внешний, untrusted) — выбор кросс-проверяется против on-chain перед доверием + chosen = await fromStakewiz().catch(() => null); + if (chosen) { + const ok = await verifyOnChain(conn ?? defaultConn(), chosen).catch(() => false); + if (!ok) { + logger.warn(`SOL validator: Stakewiz pick ${chosen} failed on-chain verification — falling back to RPC`); + chosen = null; + } + } + + // 3) RPC fallback (on-chain authoritative) + if (!chosen) { + chosen = await fromRpc(conn ?? defaultConn()).catch(() => null); + } + + // 4) last-resort + if (!chosen || !isValidVote(chosen)) { + logger.warn('SOL validator auto-select failed (Stakewiz + RPC) — using hard-coded fallback'); + chosen = FALLBACK_VOTE; + } + + try { + await getRedis().set(CACHE_KEY, chosen, 'EX', CACHE_TTL_SEC); + } catch { /* ignore */ } + + return chosen; +} + +async function fromStakewiz(): Promise { + const controller = new AbortController(); + const t = setTimeout(() => controller.abort(), HTTP_TIMEOUT_MS); + try { + const res = await fetch(STAKEWIZ_URL, { headers: { Accept: 'application/json' }, signal: controller.signal }); + if (!res.ok) return null; + const list = (await res.json()) as any[]; + if (!Array.isArray(list)) return null; + const candidates = list + .filter((v) => v && v.delinquent === false) + .filter((v) => Number(v.commission) <= 2) + .filter((v) => Number(v.skip_rate) < 1) + .filter((v) => Number(v.uptime) >= 99.8) + .filter((v) => isValidVote(v.vote_identity)) + .sort((a, b) => Number(b.wiz_score) - Number(a.wiz_score)); + return candidates.length ? String(candidates[0].vote_identity) : null; + } catch { + return null; + } finally { + clearTimeout(t); + } +} + +/** + * Кросс-проверка: vote-адрес (напр. от Stakewiz) ДОЛЖЕН существовать on-chain в current[], + * не быть delinquent, и иметь commission ≤ 10%. Защита от спуфнутого/устаревшего внешнего ответа. + */ +async function verifyOnChain(conn: Connection, vote: string): Promise { + const accounts = await conn.getVoteAccounts(); + if (accounts.delinquent.some((v: any) => v.votePubkey === vote)) return false; + const cur = accounts.current.find((v: any) => v.votePubkey === vote); + return !!cur && Number(cur.commission) <= 10; +} + +async function fromRpc(conn: Connection): Promise { + const accounts = await conn.getVoteAccounts(); + const delinquent = new Set(accounts.delinquent.map((v: any) => v.votePubkey)); + const candidates = accounts.current + .filter((v: any) => v.epochVoteAccount === true) + .filter((v: any) => Number(v.commission) <= 5) + .filter((v: any) => !delinquent.has(v.votePubkey)) + .filter((v: any) => Number(v.activatedStake) > 100_000_000) + .map((v: any) => { + const ec = Array.isArray(v.epochCredits) ? v.epochCredits : []; + const last = ec[ec.length - 1]; + const prev = ec[ec.length - 2]; + const creditGrowth = last && prev ? Number(last[1]) - Number(prev[1]) : 0; + return { votePubkey: v.votePubkey as string, commission: Number(v.commission), creditGrowth }; + }) + .sort((a, b) => (a.commission !== b.commission ? a.commission - b.commission : b.creditGrowth - a.creditGrowth)); + return candidates.length && isValidVote(candidates[0].votePubkey) ? candidates[0].votePubkey : null; +} diff --git a/apps/api/src/lib/uniswap-subgraph.ts b/apps/api/src/lib/uniswap-subgraph.ts new file mode 100644 index 0000000..d4b1788 --- /dev/null +++ b/apps/api/src/lib/uniswap-subgraph.ts @@ -0,0 +1,160 @@ +/** + * Uniswap v3 pool stats — TVL / 24h volume / 24h fees / APR. + * + * Источник: GeckoTerminal API (бесплатный, БЕЗ ключа — та же on-chain data network что у Uniswap). + * Раньше использовался The Graph gateway, но он требует платный THEGRAPH_API_KEY (hosted service + * закрыт) → без ключа возвращал null. GeckoTerminal не требует авторизации. + * + * fees24h выводится из volume24h × feeTier (не полагаемся на отдельное поле GT). + * APR = fees24h / TVL × 365 × 100. + * + * Fallback (если GeckoTerminal недоступен): on-chain TVL = balanceOf(token0/token1) × USD-цена из + * price-oracle (CoinGecko). Объём/комиссии/APR в фолбэке остаются null (on-chain недоступны дёшево). + * + * Только чтение, НИКОГДА не влияет на суммы транзакций. KeyDB-кэш 5 мин. Всё graceful → null-поля. + */ + +import { ethers } from 'ethers'; +import { getRedis } from '../config/redis'; +import { logger } from './logger'; +import { ETH_RPCS, UNIV3_POOL_ABI, ERC20_META_ABI } from './defi-contracts'; +import { pickProxiedEvmProvider } from './outbound-proxy'; +import { getPricesBySymbols } from '../services/price-oracle.service'; + +const GECKOTERMINAL_POOL_URL = (addr: string) => + `https://api.geckoterminal.com/api/v2/networks/eth/pools/${addr}`; +const CACHE_TTL_SEC = 300; +const HTTP_TIMEOUT_MS = 8000; + +export interface PoolStats { + poolAddress: string; + totalValueLockedUSD: number | null; + volumeUSD24h: number | null; + feesUSD24h: number | null; + aprPercent: number | null; +} + +/** + * Статистика пула (с кэшем). null-поля если источники недоступны. + * @param feeTier uint24 fee пула (напр. 3000 = 0.3%) — нужен для расчёта fees/APR. Если не задан, + * fees/APR останутся null. + */ +export async function getPoolStats(poolAddress: string, feeTier?: number): Promise { + const id = poolAddress.toLowerCase(); + const cacheKey = `univ3:poolstats:${id}`; + + try { + const cached = await getRedis().get(cacheKey); + if (cached) return JSON.parse(cached) as PoolStats; + } catch { /* ignore cache errors */ } + + const empty: PoolStats = { + poolAddress: id, + totalValueLockedUSD: null, + volumeUSD24h: null, + feesUSD24h: null, + aprPercent: null, + }; + + let stats = empty; + + // ── Primary: GeckoTerminal ── + const gt = await fetchGeckoTerminal(id, feeTier); + if (gt) { + stats = gt; + } else { + // ── Fallback: on-chain TVL (balances × prices) ── + const onchainTvl = await fetchOnChainTvl(id).catch(() => null); + if (onchainTvl !== null) { + stats = { ...empty, totalValueLockedUSD: onchainTvl }; + } + } + + try { + await getRedis().set(cacheKey, JSON.stringify(stats), 'EX', CACHE_TTL_SEC); + } catch { /* ignore */ } + + return stats; +} + +async function fetchGeckoTerminal(id: string, feeTier?: number): Promise { + const controller = new AbortController(); + const t = setTimeout(() => controller.abort(), HTTP_TIMEOUT_MS); + try { + const res = await fetch(GECKOTERMINAL_POOL_URL(id), { + headers: { Accept: 'application/json' }, + signal: controller.signal, + }); + if (!res.ok) { + logger.warn(`GeckoTerminal HTTP ${res.status} for pool ${id}`); + return null; + } + const j: any = await res.json(); + const attr = j?.data?.attributes; + if (!attr) return null; + + const tvl = num(attr.reserve_in_usd); + const vol24h = num(attr.volume_usd?.h24); + // fees = volume × feeRate. feeTier uint24: 3000 = 0.3% = 3000/1e6. + const fees24h = vol24h !== null && feeTier ? vol24h * (feeTier / 1_000_000) : null; + const apr = fees24h !== null && tvl && tvl > 0 ? (fees24h / tvl) * 365 * 100 : null; + + return { + poolAddress: id, + totalValueLockedUSD: tvl, + volumeUSD24h: vol24h, + feesUSD24h: fees24h, + aprPercent: apr, + }; + } catch (err: any) { + logger.warn(`GeckoTerminal fetch failed for ${id}: ${err?.message}`); + return null; + } finally { + clearTimeout(t); + } +} + +/** Best-effort on-chain TVL: balanceOf(token0)+balanceOf(token1) × USD-цена по символу. */ +async function fetchOnChainTvl(poolAddr: string): Promise { + const provider = await pickProxiedEvmProvider(ETH_RPCS, 1); + const pool = new ethers.Contract(poolAddr, UNIV3_POOL_ABI, provider); + const [token0, token1] = await Promise.all([ + pool.token0() as Promise, + pool.token1() as Promise, + ]); + const t0 = new ethers.Contract(token0, ERC20_META_ABI, provider); + const t1 = new ethers.Contract(token1, ERC20_META_ABI, provider); + const [dec0, dec1, sym0, sym1, bal0, bal1] = await Promise.all([ + t0.decimals() as Promise, + t1.decimals() as Promise, + t0.symbol().catch(() => '') as Promise, + t1.symbol().catch(() => '') as Promise, + t0.balanceOf(poolAddr) as Promise, + t1.balanceOf(poolAddr) as Promise, + ]); + + const prices = await getPricesBySymbols([ + { chain: 'ETH', symbol: normSym(sym0) }, + { chain: 'ETH', symbol: normSym(sym1) }, + ]); + const p0 = prices.get(`ETH:${normSym(sym0)}`); + const p1 = prices.get(`ETH:${normSym(sym1)}`); + if (p0 == null && p1 == null) return null; + + const human = (bal: ethers.BigNumber, dec: number) => Number(ethers.utils.formatUnits(bal, dec)); + let tvl = 0; + if (p0 != null) tvl += human(bal0, Number(dec0)) * p0; + if (p1 != null) tvl += human(bal1, Number(dec1)) * p1; + return Number.isFinite(tvl) && tvl > 0 ? tvl : null; +} + +/** WETH→ETH для резолва цены через token-registry (WETH часто не зарегистрирован отдельно). */ +function normSym(sym: string): string { + const s = (sym || '').toUpperCase(); + return s === 'WETH' ? 'ETH' : s; +} + +function num(v: unknown): number | null { + const n = Number(v); + return Number.isFinite(n) ? n : null; +} diff --git a/apps/api/src/lib/uniswap-v3-math.ts b/apps/api/src/lib/uniswap-v3-math.ts new file mode 100644 index 0000000..9b87a68 --- /dev/null +++ b/apps/api/src/lib/uniswap-v3-math.ts @@ -0,0 +1,133 @@ +/** + * Uniswap v3 concentrated-liquidity математика на BigInt (без внешнего SDK). + * + * - getSqrtRatioAtTick — каноничный TickMath (точный, magic constants как в v3-core). + * - priceToTick — человеческая цена (token1/token0) → tick (приблизительно, затем align). + * - getLiquidityForAmounts / getAmountsForLiquidity — каноничный LiquidityAmounts. + */ + +export const Q96 = 2n ** 96n; +export const MIN_TICK = -887272; +export const MAX_TICK = 887272; + +/** + * TickMath.getSqrtRatioAtTick — sqrtPriceX96 = floor(sqrt(1.0001^tick) * 2^96). + * Точная битовая реализация (как в Uniswap v3-core). + */ +export function getSqrtRatioAtTick(tick: number): bigint { + if (tick < MIN_TICK || tick > MAX_TICK) throw new Error(`tick ${tick} out of range`); + const absTick = BigInt(tick < 0 ? -tick : tick); + + let ratio = (absTick & 0x1n) !== 0n + ? 0xfffcb933bd6fad37aa2d162d1a594001n + : 0x100000000000000000000000000000000n; + const m = (c: bigint) => { ratio = (ratio * c) >> 128n; }; + if ((absTick & 0x2n) !== 0n) m(0xfff97272373d413259a46990580e213an); + if ((absTick & 0x4n) !== 0n) m(0xfff2e50f5f656932ef12357cf3c7fdccn); + if ((absTick & 0x8n) !== 0n) m(0xffe5caca7e10e4e61c3624eaa0941cd0n); + if ((absTick & 0x10n) !== 0n) m(0xffcb9843d60f6159c9db58835c926644n); + if ((absTick & 0x20n) !== 0n) m(0xff973b41fa98c081472e6896dfb254c0n); + if ((absTick & 0x40n) !== 0n) m(0xff2ea16466c96a3843ec78b326b52861n); + if ((absTick & 0x80n) !== 0n) m(0xfe5dee046a99a2a811c461f1969c3053n); + if ((absTick & 0x100n) !== 0n) m(0xfcbe86c7900a88aedcffc83b479aa3a4n); + if ((absTick & 0x200n) !== 0n) m(0xf987a7253ac413176f2b074cf7815e54n); + if ((absTick & 0x400n) !== 0n) m(0xf3392b0822b70005940c7a398e4b70f3n); + if ((absTick & 0x800n) !== 0n) m(0xe7159475a2c29b7443b29c7fa6e889d9n); + if ((absTick & 0x1000n) !== 0n) m(0xd097f3bdfd2022b8845ad8f792aa5825n); + if ((absTick & 0x2000n) !== 0n) m(0xa9f746462d870fdf8a65dc1f90e061e5n); + if ((absTick & 0x4000n) !== 0n) m(0x70d869a156d2a1b890bb3df62baf32f7n); + if ((absTick & 0x8000n) !== 0n) m(0x31be135f97d08fd981231505542fcfa6n); + if ((absTick & 0x10000n) !== 0n) m(0x9aa508b5b7a84e1c677de54f3e99bc9n); + if ((absTick & 0x20000n) !== 0n) m(0x5d6af8dedb81196699c329225ee604n); + if ((absTick & 0x40000n) !== 0n) m(0x2216e584f5fa1ea926041bedfe98n); + if ((absTick & 0x80000n) !== 0n) m(0x48a170391f7dc42444e8fa2n); + + if (tick > 0) ratio = ((2n ** 256n) - 1n) / ratio; + + // ratio is Q128.128 → sqrtPriceX96 (Q96): shift right 32, round up + const shifted = ratio >> 32n; + return (ratio & ((1n << 32n) - 1n)) === 0n ? shifted : shifted + 1n; +} + +/** Человеческая цена token1/token0 → tick (приблизительно). Затем выравнивай alignTick. */ +export function priceToTick(humanPrice: number, dec0: number, dec1: number): number { + if (!(humanPrice > 0)) throw new Error('price must be > 0'); + const priceRaw = humanPrice * Math.pow(10, dec1 - dec0); + const tick = Math.round(Math.log(priceRaw) / Math.log(1.0001)); + return Math.min(Math.max(tick, MIN_TICK), MAX_TICK); +} + +/** Tick → человеческая цена token1/token0 (для отображения). */ +export function tickToPrice(tick: number, dec0: number, dec1: number): number { + const priceRaw = Math.pow(1.0001, tick); + return priceRaw * Math.pow(10, dec0 - dec1); +} + +/** sqrtPriceX96 → человеческая цена token1/token0. */ +export function sqrtPriceX96ToPrice(sqrtPriceX96: bigint, dec0: number, dec1: number): number { + const ratio = Number(sqrtPriceX96) / Number(Q96); + const priceRaw = ratio * ratio; + return priceRaw * Math.pow(10, dec0 - dec1); +} + +export function alignTick(tick: number, spacing: number): number { + const aligned = Math.round(tick / spacing) * spacing; + return Math.min(Math.max(aligned, MIN_TICK), MAX_TICK); +} + +// ── LiquidityAmounts ─────────────────────────────────────────────────── + +function sortSqrt(a: bigint, b: bigint): [bigint, bigint] { + return a > b ? [b, a] : [a, b]; +} + +export function getLiquidityForAmount0(sqrtA: bigint, sqrtB: bigint, amount0: bigint): bigint { + [sqrtA, sqrtB] = sortSqrt(sqrtA, sqrtB); + const intermediate = (sqrtA * sqrtB) / Q96; + return (amount0 * intermediate) / (sqrtB - sqrtA); +} + +export function getLiquidityForAmount1(sqrtA: bigint, sqrtB: bigint, amount1: bigint): bigint { + [sqrtA, sqrtB] = sortSqrt(sqrtA, sqrtB); + return (amount1 * Q96) / (sqrtB - sqrtA); +} + +export function getLiquidityForAmounts( + sqrtP: bigint, sqrtA: bigint, sqrtB: bigint, amount0: bigint, amount1: bigint, +): bigint { + [sqrtA, sqrtB] = sortSqrt(sqrtA, sqrtB); + if (sqrtP <= sqrtA) return getLiquidityForAmount0(sqrtA, sqrtB, amount0); + if (sqrtP < sqrtB) { + const l0 = getLiquidityForAmount0(sqrtP, sqrtB, amount0); + const l1 = getLiquidityForAmount1(sqrtA, sqrtP, amount1); + return l0 < l1 ? l0 : l1; + } + return getLiquidityForAmount1(sqrtA, sqrtB, amount1); +} + +export function getAmount0ForLiquidity(sqrtA: bigint, sqrtB: bigint, L: bigint): bigint { + [sqrtA, sqrtB] = sortSqrt(sqrtA, sqrtB); + if (sqrtA <= 0n) throw new Error('sqrt price must be positive'); // guard div-by-zero + return (((L << 96n) * (sqrtB - sqrtA)) / sqrtB) / sqrtA; +} + +export function getAmount1ForLiquidity(sqrtA: bigint, sqrtB: bigint, L: bigint): bigint { + [sqrtA, sqrtB] = sortSqrt(sqrtA, sqrtB); + return (L * (sqrtB - sqrtA)) / Q96; +} + +export function getAmountsForLiquidity( + sqrtP: bigint, sqrtA: bigint, sqrtB: bigint, L: bigint, +): { amount0: bigint; amount1: bigint } { + [sqrtA, sqrtB] = sortSqrt(sqrtA, sqrtB); + if (sqrtP <= sqrtA) { + return { amount0: getAmount0ForLiquidity(sqrtA, sqrtB, L), amount1: 0n }; + } + if (sqrtP < sqrtB) { + return { + amount0: getAmount0ForLiquidity(sqrtP, sqrtB, L), + amount1: getAmount1ForLiquidity(sqrtA, sqrtP, L), + }; + } + return { amount0: 0n, amount1: getAmount1ForLiquidity(sqrtA, sqrtB, L) }; +} diff --git a/apps/api/src/routes/approvals.routes.ts b/apps/api/src/routes/approvals.routes.ts new file mode 100644 index 0000000..0930a36 --- /dev/null +++ b/apps/api/src/routes/approvals.routes.ts @@ -0,0 +1,12 @@ +import { Router } from 'express'; +import { scanApprovals, revokeApproval, revokeAllApprovals } from '../controllers/approvals.controller'; + +const router = Router(); + +// Mounted at /api/wallets — пути /:chain/approvals* не пересекаются с wallet/staking/lp routes. +// IMPORTANT: более специфичные /:chain/approvals/revoke* раньше /:chain/approvals. +router.post('/:chain/approvals/revoke-all', revokeAllApprovals); +router.post('/:chain/approvals/revoke', revokeApproval); +router.get('/:chain/approvals', scanApprovals); + +export default router; diff --git a/apps/api/src/routes/lp.routes.ts b/apps/api/src/routes/lp.routes.ts new file mode 100644 index 0000000..bf95303 --- /dev/null +++ b/apps/api/src/routes/lp.routes.ts @@ -0,0 +1,13 @@ +import { Router } from 'express'; +import { lpPools, lpQuote, lpAdd, lpRemove, lpPositions } from '../controllers/lp.controller'; + +const router = Router(); + +// Mounted at /api/wallets — пути /:chain/lp/* не пересекаются с wallet/staking routes. +router.get('/:chain/lp/pools', lpPools); +router.post('/:chain/lp/quote', lpQuote); +router.post('/:chain/lp/add', lpAdd); +router.post('/:chain/lp/remove', lpRemove); +router.get('/:chain/lp/positions', lpPositions); + +export default router; diff --git a/apps/api/src/routes/staking.routes.ts b/apps/api/src/routes/staking.routes.ts new file mode 100644 index 0000000..db51e9b --- /dev/null +++ b/apps/api/src/routes/staking.routes.ts @@ -0,0 +1,20 @@ +import { Router } from 'express'; +import { + stakeQuote, + stakeExecute, + stakePositions, + unstakeExecute, + unstakeClaim, +} from '../controllers/staking.controller'; + +const router = Router(); + +// Mounted at /api/wallets — paths не пересекаются с wallet.routes (нет /:chain/stake там). +// IMPORTANT: более специфичные пути раньше: /:chain/stake/quote ПЕРЕД /:chain/stake. +router.post('/:chain/stake/quote', stakeQuote); +router.post('/:chain/stake', stakeExecute); +router.get('/:chain/stake/positions', stakePositions); +router.post('/:chain/unstake/claim', unstakeClaim); +router.post('/:chain/unstake', unstakeExecute); + +export default router; diff --git a/apps/api/src/routes/wallet.routes.ts b/apps/api/src/routes/wallet.routes.ts index 0b50040..6f22798 100644 --- a/apps/api/src/routes/wallet.routes.ts +++ b/apps/api/src/routes/wallet.routes.ts @@ -23,6 +23,8 @@ router.post('/:chain/sign-raw-evm-tx', WalletController.signRawEvmTx); router.post('/:chain/swap/quote', WalletController.quoteSwap); router.post('/:chain/swap/cost-estimate', WalletController.estimateSwapCost); router.post('/:chain/swap', WalletController.swapOnChain); +// Read-only «стоимость операции» (наша 0.7% в native+USD + проверка баланса токена/газа) для свапа/бриджа. +router.post('/:chain/op-cost', WalletController.opCost); router.post('/:chain/app-fee', WalletController.appFeeTransfer); router.post('/SOL/sign-and-broadcast-tx', WalletController.signSolanaTx); diff --git a/apps/api/src/services/sol-stake.service.ts b/apps/api/src/services/sol-stake.service.ts new file mode 100644 index 0000000..97b5db3 --- /dev/null +++ b/apps/api/src/services/sol-stake.service.ts @@ -0,0 +1,331 @@ +/** + * Native Solana staking — custodial (эквивалент Phantom). + * + * stake: SystemProgram.createAccount(stake) + StakeProgram.initialize + StakeProgram.delegate(validator) + * unstake: StakeProgram.deactivate → (cooldown 1-2 эпохи) → StakeProgram.withdraw + * positions: getParsedProgramAccounts(StakeProgram) по staker-authority = адрес юзера (без БД) + * + * Валидатор — авто-выбор (resolveStakeValidator: env override → Stakewiz → RPC → fallback). Stake-аккаунт — + * свеже-сгенерённый keypair (подписывает создание как extraSigner); его адрес возвращается и + * заново находится on-chain. App-fee 0.7% — нативный SOL-перевод в той же транзакции. + */ + +import { + Connection, + PublicKey, + Keypair, + SystemProgram, + StakeProgram, + Authorized, + Lockup, + TransactionInstruction, +} from '@solana/web3.js'; +import { getAppFeeWallet, computeAppFee } from '../lib/app-fee'; +import { formatSmallestUnits } from '../lib/amount-units'; +import { signAndBroadcastSolBuilt } from './wallet-signer.service'; +import { StakingError } from './staking.service'; +import { resolveStakeValidator } from '../lib/sol-validator'; + +function badRequest(msg: string): StakingError { + return new StakingError(msg, 'BAD_REQUEST', 400); +} + +/** SOL = 9 decimals (lamports → SOL human-строка). */ +const sol = (lamports: string): string => formatSmallestUnits(lamports, 9); + +const SOL_RPC = 'https://api.mainnet-beta.solana.com'; +let _conn: Connection | null = null; +function getConn(): Connection { + if (!_conn) _conn = new Connection(SOL_RPC, 'confirmed'); + return _conn; +} + +const MAX_SAFE = BigInt(Number.MAX_SAFE_INTEGER); + +export interface SolStakeQuote { + amountLamports: string; + amountLamportsHuman: string; + appFeeLamports: string; + appFeeLamportsHuman: string; + stakeLamports: string; + stakeLamportsHuman: string; + validator: string; + estApyPercent: number; +} + +/** Превью: fee + сумма после fee + валидатор. APY — приблизительная оценка сети (~6.5%). */ +export async function quoteSolStake(amountLamports: string): Promise { + if (typeof amountLamports !== 'string' || !/^\d+$/.test(amountLamports) || amountLamports === '0') { + throw badRequest('amount must be a positive integer string (lamports)'); + } + const fee = computeAppFee(amountLamports); + const stake = BigInt(amountLamports) - fee; + if (stake <= 0n) throw badRequest('amount too small after 0.7% fee'); + const validator = await resolveStakeValidator(getConn()); + return { + amountLamports, + amountLamportsHuman: sol(amountLamports), + appFeeLamports: fee.toString(), + appFeeLamportsHuman: sol(fee.toString()), + stakeLamports: stake.toString(), + stakeLamportsHuman: sol(stake.toString()), + validator, + estApyPercent: 6.5, + }; +} + +export interface SolStakeResult { + stakeTxid: string; + stakeAccount: string; + stakeLamports: string; + stakeLamportsHuman: string; + appFeeLamports: string; + appFeeLamportsHuman: string; + validator: string; +} + +/** stake: create+delegate в одной транзакции (+ app-fee). */ +export async function executeSolStake(p: { + mnemonic: string; + expectedFromAddress: string; + amountLamports: string; +}): Promise { + if (typeof p.amountLamports !== 'string' || !/^\d+$/.test(p.amountLamports) || p.amountLamports === '0') { + throw badRequest('amount must be a positive integer string (lamports)'); + } + const fee = computeAppFee(p.amountLamports); + const stakeLamports = BigInt(p.amountLamports) - fee; + if (stakeLamports <= 0n) throw badRequest('amount too small after 0.7% fee'); + if (stakeLamports > MAX_SAFE) throw badRequest('amount exceeds Number precision; split into multiple stakes'); + + const conn = getConn(); + const rentExempt = BigInt(await conn.getMinimumBalanceForRentExemption(StakeProgram.space)); + if (stakeLamports <= rentExempt) { + throw badRequest(`amount too small: must exceed rent-exempt reserve (${rentExempt} lamports) after fee`); + } + + const user = new PublicKey(p.expectedFromAddress); + const validator = await resolveStakeValidator(conn); + const votePubkey = new PublicKey(validator); + const stakeAccount = Keypair.generate(); + + const instructions: TransactionInstruction[] = []; + + // app fee 0.7% (native SOL) + if (fee > MAX_SAFE) throw badRequest('fee exceeds Number precision; amount too large'); + if (fee > 0n) { + instructions.push( + SystemProgram.transfer({ + fromPubkey: user, + toPubkey: new PublicKey(getAppFeeWallet('SOL')), + lamports: Number(fee), + }), + ); + } + + // create + initialize stake account (StakeProgram.createAccount возвращает Transaction) + const createTx = StakeProgram.createAccount({ + fromPubkey: user, + stakePubkey: stakeAccount.publicKey, + authorized: new Authorized(user, user), + lockup: new Lockup(0, 0, PublicKey.default), + lamports: Number(stakeLamports), + }); + instructions.push(...createTx.instructions); + + // delegate к валидатору + const delegateTx = StakeProgram.delegate({ + stakePubkey: stakeAccount.publicKey, + authorizedPubkey: user, + votePubkey, + }); + instructions.push(...delegateTx.instructions); + + const { signature } = await signAndBroadcastSolBuilt({ + mnemonic: p.mnemonic, + expectedFromAddress: p.expectedFromAddress, + instructions, + extraSigners: [stakeAccount], + }); + + return { + stakeTxid: signature, + stakeAccount: stakeAccount.publicKey.toBase58(), + stakeLamports: stakeLamports.toString(), + stakeLamportsHuman: sol(stakeLamports.toString()), + appFeeLamports: fee.toString(), + appFeeLamportsHuman: sol(fee.toString()), + validator, + }; +} + +export interface SolUnstakeResult { + action: 'deactivate' | 'withdraw'; + txid: string; + withdrawnLamports?: string; + withdrawnLamportsHuman?: string; + note?: string; +} + +/** + * unstake: один эндпоинт, два состояния. + * active/activating → deactivate (дальше cooldown 1-2 эпохи) + * inactive → withdraw всех lamports на адрес юзера + * deactivating → ещё рано (cooldown) + */ +export async function executeSolUnstake(p: { + mnemonic: string; + expectedFromAddress: string; + stakeAccount: string; +}): Promise { + let stakePubkey: PublicKey; + try { + stakePubkey = new PublicKey(p.stakeAccount); + } catch { + throw badRequest('invalid stakeAccount address'); + } + const user = new PublicKey(p.expectedFromAddress); + const conn = getConn(); + + // ownership/authority guard: stake account должен иметь staker-authority = адрес юзера + await assertStakeOwnedByUser(conn, stakePubkey, p.expectedFromAddress); + + const state = await readStakeState(conn, stakePubkey); + + if (state === 'active' || state === 'activating') { + const tx = StakeProgram.deactivate({ stakePubkey, authorizedPubkey: user }); + const { signature } = await signAndBroadcastSolBuilt({ + mnemonic: p.mnemonic, + expectedFromAddress: p.expectedFromAddress, + instructions: tx.instructions, + }); + return { + action: 'deactivate', + txid: signature, + note: 'Деактивация запущена. Через 1-2 эпохи (cooldown сети) вызови /unstake снова — выведет средства.', + }; + } + + if (state === 'deactivating') { + throw badRequest('stake is deactivating; wait 1-2 epochs (network cooldown), then call /unstake again to withdraw'); + } + + // inactive → withdraw + const acct = await conn.getAccountInfo(stakePubkey); + const lamports = acct?.lamports ?? 0; + if (lamports <= 0) throw badRequest('stake account empty or not found'); + if (lamports > Number.MAX_SAFE_INTEGER) throw badRequest('stake balance exceeds safe integer limit'); + const wtx = StakeProgram.withdraw({ + stakePubkey, + authorizedPubkey: user, + toPubkey: user, + lamports, + }); + const { signature } = await signAndBroadcastSolBuilt({ + mnemonic: p.mnemonic, + expectedFromAddress: p.expectedFromAddress, + instructions: wtx.instructions, + }); + return { action: 'withdraw', txid: signature, withdrawnLamports: String(lamports), withdrawnLamportsHuman: sol(String(lamports)) }; +} + +export interface SolStakePosition { + stakeAccount: string; + lamports: string; + lamportsHuman: string; + state: string; + validator: string | null; + delegatedLamports: string | null; + delegatedLamportsHuman: string | null; +} + +export interface SolStakePositions { + protocol: 'native'; + chain: 'SOL'; + validator: string | null; + positions: SolStakePosition[]; +} + +/** Позиции: все stake-аккаунты с staker-authority = адрес юзера. */ +export async function readSolStakePositions(address: string): Promise { + const conn = getConn(); + let user: PublicKey; + try { + user = new PublicKey(address); + } catch { + throw badRequest('invalid SOL address'); + } + + // Stake account layout: state(4) + Meta{ rentReserve(8) + Authorized{ staker(32)@12, withdrawer(32)@44 } ... } + const accounts = await conn.getParsedProgramAccounts(StakeProgram.programId, { + filters: [{ memcmp: { offset: 12, bytes: user.toBase58() } }], + }); + + const positions: SolStakePosition[] = []; + for (const a of accounts) { + const info: any = (a.account.data as any)?.parsed?.info; + const state = await readStakeState(conn, a.pubkey, info); + const delegated = info?.stake?.delegation?.stake ?? null; + positions.push({ + stakeAccount: a.pubkey.toBase58(), + lamports: String(a.account.lamports), + lamportsHuman: sol(String(a.account.lamports)), + state, + validator: info?.stake?.delegation?.voter ?? null, + delegatedLamports: delegated, + delegatedLamportsHuman: delegated != null ? sol(String(delegated)) : null, + }); + } + + return { + protocol: 'native', + chain: 'SOL', + validator: await resolveStakeValidator(conn).catch(() => null), + positions, + }; +} + +/** + * Состояние стейка с fallback. getStakeActivation() есть в @solana/web3.js 1.98, но многие + * публичные RPC его не держат → fallback на разбор parsed account info (delegation). + */ +async function readStakeState( + conn: Connection, + stakePubkey: PublicKey, + parsedInfo?: any, +): Promise { + try { + return (await conn.getStakeActivation(stakePubkey)).state; + } catch { + let info = parsedInfo; + if (!info) { + try { + const acc = await conn.getParsedAccountInfo(stakePubkey); + info = (acc.value?.data as any)?.parsed?.info; + } catch { + return 'unknown'; + } + } + const deleg = info?.stake?.delegation; + if (!deleg) return info ? 'inactive' : 'unknown'; + // deactivationEpoch == максимум u64 → ещё активен; иначе помечен на деактивацию. + const deactEpoch = String(deleg.deactivationEpoch ?? ''); + const MAX_U64 = '18446744073709551615'; + const hasStake = deleg.stake && BigInt(deleg.stake) > 0n; + if (deactEpoch && deactEpoch !== MAX_U64) return 'deactivating'; + return hasStake ? 'active' : 'inactive'; + } +} + +/** Проверка: staker-authority stake-аккаунта = адрес юзера (защита от unstake чужого аккаунта). */ +async function assertStakeOwnedByUser(conn: Connection, stakePubkey: PublicKey, userAddress: string): Promise { + const acc = await conn.getParsedAccountInfo(stakePubkey); + const info: any = (acc.value?.data as any)?.parsed?.info; + const staker = info?.meta?.authorized?.staker; + if (!staker) { + throw badRequest('not a stake account or unreadable'); + } + if (staker !== userAddress) { + throw badRequest('stake account not owned by user (staker authority mismatch)'); + } +} diff --git a/apps/api/src/services/staking.service.ts b/apps/api/src/services/staking.service.ts new file mode 100644 index 0000000..467745d --- /dev/null +++ b/apps/api/src/services/staking.service.ts @@ -0,0 +1,419 @@ +/** + * Staking orchestrator — custodial. + * + * Milestone 1: Lido ETH staking (stake / quote / positions). + * Паттерн как в bridge-execute.service.ts: сервер строит calldata, отнимает app-fee 0.7%, + * затем подписывает+бродкастит через wallet-signer. Mnemonic не покидает API. + * + * Lido `submit(referral)` payable минтит stETH ~1:1 к внесённому ETH. stETH — ребейзный ERC20, + * поэтому позиция = просто stETH balanceOf(адрес юзера); отдельная таблица в БД не нужна. + */ + +import { ethers } from 'ethers'; +import { pickProxiedEvmProvider } from '../lib/outbound-proxy'; +import { + ETH_RPCS, + lidoStEthAddress, + lidoWithdrawalQueueAddress, + curveStEthPoolAddress, + assertDefiEvmTarget, + LIDO_ABI, + LIDO_WQ_ABI, + CURVE_STETH_ABI, +} from '../lib/defi-contracts'; +import { computeAppFee } from '../lib/app-fee'; +import { formatSmallestUnits } from '../lib/amount-units'; +import { signAndBroadcastEvmFeeTx, signAndBroadcastRawEvm } from './wallet-signer.service'; +import { signAndBroadcastEvmApprove } from './wallet-signer-bridge'; + +/** ETH/stETH/WETH = 18 decimals → human-строка. */ +const eth = (wei: string): string => formatSmallestUnits(wei, 18); + +/** Типизированная ошибка с HTTP-кодом для контроллера. */ +export class StakingError extends Error { + constructor(message: string, public code: string, public httpStatus = 400) { + super(message); + this.name = 'StakingError'; + } +} +function badRequest(msg: string): StakingError { + return new StakingError(msg, 'BAD_REQUEST', 400); +} + +/** Lido submit обычно жжёт ~90k; 150k с запасом. */ +const STAKE_GAS_LIMIT = '150000'; + +async function getEthProvider(): Promise { + return pickProxiedEvmProvider(ETH_RPCS, 1); +} + +export interface LidoQuote { + amountWei: string; + amountWeiHuman: string; + appFeeWei: string; + appFeeWeiHuman: string; + stakeAmountWei: string; + stakeAmountWeiHuman: string; + expectedStEthWei: string; + expectedStEthWeiHuman: string; + aprPercent: number | null; +} + +/** Превью стейка: fee, сумма после fee, ожидаемый stETH (~1:1), APR. */ +export async function quoteLidoStake(amountWei: string): Promise { + if (typeof amountWei !== 'string' || !/^\d+$/.test(amountWei) || amountWei === '0') { + throw badRequest('amount must be a positive integer string (wei)'); + } + const fee = computeAppFee(amountWei); + const stakeAmount = BigInt(amountWei) - fee; + if (stakeAmount <= 0n) throw badRequest('amount too small after 0.7% fee'); + const apr = await fetchLidoApr(); + return { + amountWei, + amountWeiHuman: eth(amountWei), + appFeeWei: fee.toString(), + appFeeWeiHuman: eth(fee.toString()), + stakeAmountWei: stakeAmount.toString(), + stakeAmountWeiHuman: eth(stakeAmount.toString()), + // Lido минтит stETH ~1:1 к внесённому ETH (после fee) + expectedStEthWei: stakeAmount.toString(), + expectedStEthWeiHuman: eth(stakeAmount.toString()), + aprPercent: apr, + }; +} + +export interface LidoStakeResult { + feeTxid: string; + stakeTxid: string; + stakeAmountWei: string; + stakeAmountWeiHuman: string; + appFeeWei: string; + appFeeWeiHuman: string; +} + +/** + * Выполнить стейк: (1) app-fee native ETH (atomic, ждёт 1 conf), (2) Lido submit(referral) payable. + * Nonce-последовательность: fee использует nonce N (wait 1 conf) → submit берёт pending = N+1. + */ +export async function executeLidoStake(p: { + mnemonic: string; + expectedFromAddress: string; + amountWei: string; +}): Promise { + if (typeof p.amountWei !== 'string' || !/^\d+$/.test(p.amountWei) || p.amountWei === '0') { + throw badRequest('amount must be a positive integer string (wei)'); + } + const fee = computeAppFee(p.amountWei); + const stakeAmount = BigInt(p.amountWei) - fee; + if (stakeAmount <= 0n) throw badRequest('amount too small after 0.7% fee'); + + // 1) app fee (native ETH) — отдельная транза, ждёт 1 conf; если revert → throw, submit не уйдёт + const feeRes = await signAndBroadcastEvmFeeTx({ + chain: 'ETH', + mnemonic: p.mnemonic, + expectedFromAddress: p.expectedFromAddress, + bridgeAmount: p.amountWei, + bridgeToken: null, // native + feeTier: 'normal', + }); + + // 2) Lido submit(referral) payable, value = stakeAmount + const to = lidoStEthAddress(); + assertDefiEvmTarget(to); + const data = new ethers.utils.Interface(LIDO_ABI).encodeFunctionData('submit', [ethers.constants.AddressZero]); + const stakeRes = await signAndBroadcastRawEvm({ + chain: 'ETH', + mnemonic: p.mnemonic, + expectedFromAddress: p.expectedFromAddress, + feeTier: 'normal', // override fees из gas-oracle (tx.maxFee* игнорятся) + tx: { + to, + data, + value: stakeAmount.toString(), + chainId: 1, + gas: STAKE_GAS_LIMIT, + maxFeePerGas: '0', + maxPriorityFeePerGas: '0', + }, + }); + + return { + feeTxid: feeRes.feeTxid, + stakeTxid: stakeRes.txid, + stakeAmountWei: stakeAmount.toString(), + stakeAmountWeiHuman: eth(stakeAmount.toString()), + appFeeWei: fee.toString(), + appFeeWeiHuman: eth(fee.toString()), + }; +} + +export interface LidoWithdrawalRequest { + requestId: string; + amountStEthWei: string; + amountStEthWeiHuman: string; + isFinalized: boolean; + isClaimed: boolean; +} + +export interface LidoPositions { + protocol: 'lido'; + chain: 'ETH'; + stEthBalanceWei: string; + stEthBalanceWeiHuman: string; + aprPercent: number | null; + withdrawalRequests: LidoWithdrawalRequest[]; +} + +/** + * Позиция = stETH balanceOf(юзера) + pending withdrawal requests (mode=queue). + * Читается live из чейна (без БД). + */ +export async function readLidoPositions(ethAddress: string): Promise { + const provider = await getEthProvider(); + const stEth = new ethers.Contract(lidoStEthAddress(), LIDO_ABI, provider); + const wq = new ethers.Contract(lidoWithdrawalQueueAddress(), LIDO_WQ_ABI, provider); + + const [bal, apr, requests] = await Promise.all([ + stEth.balanceOf(ethAddress) as Promise, + fetchLidoApr(), + readWithdrawalRequests(wq, ethAddress), + ]); + + return { + protocol: 'lido', + chain: 'ETH', + stEthBalanceWei: bal.toString(), + stEthBalanceWeiHuman: eth(bal.toString()), + aprPercent: apr, + withdrawalRequests: requests, + }; +} + +async function readWithdrawalRequests( + wq: ethers.Contract, + owner: string, +): Promise { + try { + const ids: ethers.BigNumber[] = await wq.getWithdrawalRequests(owner); + if (!ids.length) return []; + const statuses: any[] = await wq.getWithdrawalStatus(ids); + return ids.map((id, i) => ({ + requestId: id.toString(), + amountStEthWei: statuses[i]?.amountOfStETH?.toString() ?? '0', + amountStEthWeiHuman: eth(statuses[i]?.amountOfStETH?.toString() ?? '0'), + isFinalized: Boolean(statuses[i]?.isFinalized), + isClaimed: Boolean(statuses[i]?.isClaimed), + })); + } catch { + return []; + } +} + +// ── Unstake ──────────────────────────────────────────────────────────── + +const SLIPPAGE_DENOM = 10000n; +// Авто-slippage на выводе stETH→ETH: маленький фикс (0.3%). Безопасно — get_dy читается свежим +// перед exchange, поэтому slippage покрывает только окно исполнения (peg/impact уже в get_dy). +const UNSTAKE_SWAP_SLIPPAGE_BPS = 30n; // 0.3% +const SWAP_GAS_LIMIT = '300000'; +const WQ_REQUEST_GAS_LIMIT = '350000'; +const WQ_CLAIM_GAS_LIMIT = '250000'; + +export interface LidoUnstakeSwapResult { + mode: 'swap'; + approveTxid: string; + swapTxid: string; + swapAmountStEthWei: string; + swapAmountStEthWeiHuman: string; + minEthOutWei: string; + minEthOutWeiHuman: string; +} + +/** + * mode=swap: мгновенный вывод stETH→ETH через Curve stETH/ETH пул (deepest liquidity). + * Вывод БЕЗ комиссии: approve stETH→Curve → exchange(1,0,...) на полную сумму. + */ +export async function unstakeLidoViaSwap(p: { + mnemonic: string; + expectedFromAddress: string; + amountStEthWei: string; +}): Promise { + if (typeof p.amountStEthWei !== 'string' || !/^\d+$/.test(p.amountStEthWei) || p.amountStEthWei === '0') { + throw badRequest('amount must be a positive integer string (stETH wei)'); + } + const swapAmount = BigInt(p.amountStEthWei); + + const stEth = lidoStEthAddress(); + const pool = curveStEthPoolAddress(); + + // approve stETH→Curve (полная сумма, вывод без комиссии) + const approveRes = await signAndBroadcastEvmApprove({ + chain: 'ETH', + mnemonic: p.mnemonic, + expectedFromAddress: p.expectedFromAddress, + spender: pool, + token: stEth, + amount: swapAmount.toString(), + }); + + // min_dy из get_dy с авто-slippage + const provider = await getEthProvider(); + const curve = new ethers.Contract(pool, CURVE_STETH_ABI, provider); + const dy: ethers.BigNumber = await curve.get_dy(1, 0, swapAmount.toString()); + const minDy = (BigInt(dy.toString()) * (SLIPPAGE_DENOM - UNSTAKE_SWAP_SLIPPAGE_BPS)) / SLIPPAGE_DENOM; + + // exchange(1=stETH, 0=ETH, swapAmount, minDy) — ETH прилетает юзеру + assertDefiEvmTarget(pool); + const data = new ethers.utils.Interface(CURVE_STETH_ABI).encodeFunctionData('exchange', [ + 1, + 0, + swapAmount.toString(), + minDy.toString(), + ]); + const swapRes = await signAndBroadcastRawEvm({ + chain: 'ETH', + mnemonic: p.mnemonic, + expectedFromAddress: p.expectedFromAddress, + feeTier: 'normal', + tx: { to: pool, data, value: '0', chainId: 1, gas: SWAP_GAS_LIMIT, maxFeePerGas: '0', maxPriorityFeePerGas: '0' }, + }); + + return { + mode: 'swap', + approveTxid: approveRes.txid, + swapTxid: swapRes.txid, + swapAmountStEthWei: swapAmount.toString(), + swapAmountStEthWeiHuman: eth(swapAmount.toString()), + minEthOutWei: minDy.toString(), + minEthOutWeiHuman: eth(minDy.toString()), + }; +} + +export interface LidoUnstakeQueueResult { + mode: 'queue'; + approveTxid: string; + requestTxid: string; + requestedStEthWei: string; + requestedStEthWeiHuman: string; + note: string; +} + +/** + * mode=queue: честный вывод через Lido Withdrawal Queue. БЕЗ комиссии. + * Шаги: approve stETH→WQ → requestWithdrawals([amount], owner) на полную сумму. + * requestId(ы) появятся в GET /stake/positions; затем POST /unstake/claim. + */ +export async function unstakeLidoViaQueue(p: { + mnemonic: string; + expectedFromAddress: string; + amountStEthWei: string; +}): Promise { + if (typeof p.amountStEthWei !== 'string' || !/^\d+$/.test(p.amountStEthWei) || p.amountStEthWei === '0') { + throw badRequest('amount must be a positive integer string (stETH wei)'); + } + const reqAmount = BigInt(p.amountStEthWei); + // Lido WQ ограничивает один запрос ≤ 1000 stETH + if (reqAmount > ethers.utils.parseEther('1000').toBigInt()) { + throw badRequest('single withdrawal request must be ≤ 1000 stETH; split into multiple'); + } + + const stEth = lidoStEthAddress(); + const wq = lidoWithdrawalQueueAddress(); + + const approveRes = await signAndBroadcastEvmApprove({ + chain: 'ETH', + mnemonic: p.mnemonic, + expectedFromAddress: p.expectedFromAddress, + spender: wq, + token: stEth, + amount: reqAmount.toString(), + }); + + assertDefiEvmTarget(wq); + const data = new ethers.utils.Interface(LIDO_WQ_ABI).encodeFunctionData('requestWithdrawals', [ + [reqAmount.toString()], + p.expectedFromAddress, + ]); + const reqRes = await signAndBroadcastRawEvm({ + chain: 'ETH', + mnemonic: p.mnemonic, + expectedFromAddress: p.expectedFromAddress, + feeTier: 'normal', + tx: { to: wq, data, value: '0', chainId: 1, gas: WQ_REQUEST_GAS_LIMIT, maxFeePerGas: '0', maxPriorityFeePerGas: '0' }, + }); + + return { + mode: 'queue', + approveTxid: approveRes.txid, + requestTxid: reqRes.txid, + requestedStEthWei: reqAmount.toString(), + requestedStEthWeiHuman: eth(reqAmount.toString()), + note: 'Withdrawal requested (без комиссии). Check GET /wallets/ETH/stake/positions for requestId + isFinalized, then POST /wallets/ETH/unstake/claim.', + }; +} + +/** Claim finalized withdrawal request (mode=queue). */ +export async function claimLidoWithdrawal(p: { + mnemonic: string; + expectedFromAddress: string; + requestId: string; +}): Promise<{ claimTxid: string }> { + if (typeof p.requestId !== 'string' || !/^\d+$/.test(p.requestId)) { + throw badRequest('requestId must be a positive integer string'); + } + const wq = lidoWithdrawalQueueAddress(); + + // Pre-check: requestId должен принадлежать юзеру — иначе on-chain revert + сожжённый газ. + // Деградация: если чтение упало (RPC) — не блокируем (claim сам отвергнет чужой requestId on-chain). + try { + const provider = await getEthProvider(); + const wqRead = new ethers.Contract(wq, LIDO_WQ_ABI, provider); + const ids: ethers.BigNumber[] = await wqRead.getWithdrawalRequests(p.expectedFromAddress); + if (!ids.some((id) => id.toString() === p.requestId)) { + throw badRequest('requestId not found for this user (см. GET /wallets/ETH/stake/positions)'); + } + } catch (err: any) { + if (err instanceof StakingError) throw err; + /* RPC read failure → proceed (on-chain claim защищает) */ + } + + assertDefiEvmTarget(wq); + const data = new ethers.utils.Interface(LIDO_WQ_ABI).encodeFunctionData('claimWithdrawal', [p.requestId]); + const res = await signAndBroadcastRawEvm({ + chain: 'ETH', + mnemonic: p.mnemonic, + expectedFromAddress: p.expectedFromAddress, + feeTier: 'normal', + tx: { to: wq, data, value: '0', chainId: 1, gas: WQ_CLAIM_GAS_LIMIT, maxFeePerGas: '0', maxPriorityFeePerGas: '0' }, + }); + return { claimTxid: res.txid }; +} + +// ── Lido APR (public API) с кэшем 10 мин + fallback ──────────────────── +let _aprCache: { value: number; at: number } | null = null; +const APR_TTL_MS = 10 * 60_000; + +async function fetchLidoApr(): Promise { + const now = Date.now(); + if (_aprCache && now - _aprCache.at < APR_TTL_MS) return _aprCache.value; + const controller = new AbortController(); + const t = setTimeout(() => controller.abort(), 5000); + try { + const res = await fetch('https://eth-api.lido.fi/v1/protocol/steth/apr/last', { + signal: controller.signal, + }); + if (res.ok) { + const j: any = await res.json(); + const apr = Number(j?.data?.apr ?? j?.apr); + if (Number.isFinite(apr)) { + _aprCache = { value: apr, at: now }; + return apr; + } + } + } catch { + /* network/timeout → fallback на stale/null */ + } finally { + clearTimeout(t); + } + return _aprCache?.value ?? null; +} diff --git a/apps/api/src/services/swap-orchestrator.service.ts b/apps/api/src/services/swap-orchestrator.service.ts index ab9adbb..71d9da9 100644 --- a/apps/api/src/services/swap-orchestrator.service.ts +++ b/apps/api/src/services/swap-orchestrator.service.ts @@ -1029,14 +1029,13 @@ export async function executeTrx( } // ─── USDT → TRX ─── - // Step 1: check allowance, approve infinite if needed. + // Step 1: check allowance, approve EXACT amount if needed. + // ВАЖНО: аппрувим РОВНО сумму свопа (не infinite). Точный approve самоочищается — роутер забирает + // ровно `amount`, allowance → 0. Бессрочный approval позволил бы вывести любые будущие поступления. let approveTxid: string | undefined; const allowance = await checkAllowance(fromTronAddr, USDT_CONTRACT, FEE_SWAP_ROUTER_TRX, headers); if (allowance < amount) { - const INFINITE = BigInt( - '0xffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff', - ); - const approveParam = encAddr(FEE_SWAP_ROUTER_TRX) + encU256(INFINITE); + const approveParam = encAddr(FEE_SWAP_ROUTER_TRX) + encU256(amount); const approveTx = await buildTrigger({ ownerAddress: fromTronAddr, contractAddress: USDT_CONTRACT, diff --git a/apps/api/src/services/uniswap-lp.service.ts b/apps/api/src/services/uniswap-lp.service.ts new file mode 100644 index 0000000..fad6ffa --- /dev/null +++ b/apps/api/src/services/uniswap-lp.service.ts @@ -0,0 +1,748 @@ +/** + * Uniswap v3 liquidity provision — custodial. 2 куриных пула, произвольный диапазон. + * + * getPools — список пулов: токены, fee, текущая цена, TVL, APR (subgraph). + * quoteAdd — из [priceLower, priceUpper] → тики + нужные amount0/amount1 + slippage-мины. + * executeAdd— fee 0.7% (оба токена) → approve×2 → NFPM.mint() → tokenId. + * remove — NFPM.multicall(decreaseLiquidity + collect [+ burn]). + * positions — enumerate NFPM по owner, фильтр по куриным пулам, текущая стоимость + комиссии. + * + * Цена в API — token1 per token0 (raw pool convention, human-decimal-adjusted). Текущую цену и + * символы токенов смотри в GET /lp/pools, чтобы знать в каких числах задавать диапазон. + * MVP: оба токена — ERC20 (WETH как ERC20, без нативного ETH-leg). Авто-zap — позже. + */ + +import { ethers } from 'ethers'; +import { pickProxiedEvmProvider } from '../lib/outbound-proxy'; +import { + ETH_RPCS, + uniswapNfpmAddress, + uniswapLpPools, + isCuratedPool, + assertDefiEvmTarget, + isWeth, + NFPM_ABI, + UNIV3_POOL_ABI, + ERC20_META_ABI, +} from '../lib/defi-contracts'; +import { computeAppFee } from '../lib/app-fee'; +import { formatSmallestUnits } from '../lib/amount-units'; +import { getPricesBySymbols } from './price-oracle.service'; +import { signAndBroadcastRawEvm } from './wallet-signer.service'; +import { + signAndBroadcastEvmApprove, + signAndBroadcastWrapEth, + readErc20Balance, + readEvmNativeBalance, +} from './wallet-signer-bridge'; +import { signAndBroadcastEvmFeeTx } from './wallet-signer.service'; +import { getPoolStats } from '../lib/uniswap-subgraph'; +import { + getSqrtRatioAtTick, + priceToTick, + tickToPrice, + alignTick, + sqrtPriceX96ToPrice, + getLiquidityForAmounts, + getAmountsForLiquidity, +} from '../lib/uniswap-v3-math'; +import { StakingError } from './staking.service'; +import { logger } from '../lib/logger'; + +/** Переиспользуем типизированную ошибку (контроллер маппит httpStatus). */ +function badRequest(msg: string): StakingError { + return new StakingError(msg, 'BAD_REQUEST', 400); +} + +const MINT_GAS_LIMIT = '600000'; +const REMOVE_GAS_LIMIT = '500000'; +const DEFAULT_SLIPPAGE_BPS = 100n; // 1% +const MAX_SLIPPAGE_BPS = 500n; // 5% — верхняя граница (дефолт 1%) +const SLIPPAGE_DENOM = 10000n; +const MAX_UINT128 = ((1n << 128n) - 1n).toString(); +const MAX_UINT256 = (1n << 256n) - 1n; +const POSITIONS_SCAN_CAP = 50; + +/** Валидация amount-строки в smallest units: целое, неотрицательное, ≤ uint256. */ +function parseAmountSmallest(s: string, label: string): bigint { + if (typeof s !== 'string' || !/^\d+$/.test(s)) throw badRequest(`${label} must be a non-negative integer string`); + const v = BigInt(s); + if (v > MAX_UINT256) throw badRequest(`${label} exceeds uint256 maximum`); + return v; +} + +/** Валидация slippageBps: 0..5000. Возвращает bigint bps. */ +function resolveSlippageBps(slippageBps?: number): bigint { + if (slippageBps === undefined) return DEFAULT_SLIPPAGE_BPS; + if (!Number.isFinite(slippageBps) || slippageBps < 0 || slippageBps > Number(MAX_SLIPPAGE_BPS)) { + throw badRequest(`slippageBps must be between 0 and ${Number(MAX_SLIPPAGE_BPS)}`); + } + return BigInt(Math.floor(slippageBps)); +} + +async function getProvider(): Promise { + return pickProxiedEvmProvider(ETH_RPCS, 1); +} + +interface PoolData { + poolAddress: string; + token0: string; + token1: string; + fee: number; + tickSpacing: number; + sqrtPriceX96: bigint; + tick: number; + liquidity: bigint; + dec0: number; + dec1: number; + sym0: string; + sym1: string; +} + +async function readPool(provider: ethers.providers.Provider, poolAddr: string): Promise { + const pool = new ethers.Contract(poolAddr, UNIV3_POOL_ABI, provider); + const [token0, token1, fee, tickSpacing, slot0, liquidity] = await Promise.all([ + pool.token0() as Promise, + pool.token1() as Promise, + pool.fee() as Promise, + pool.tickSpacing() as Promise, + pool.slot0() as Promise, + pool.liquidity() as Promise, + ]); + const t0 = new ethers.Contract(token0, ERC20_META_ABI, provider); + const t1 = new ethers.Contract(token1, ERC20_META_ABI, provider); + const [dec0, dec1, sym0, sym1] = await Promise.all([ + t0.decimals() as Promise, + t1.decimals() as Promise, + t0.symbol().catch(() => '?') as Promise, + t1.symbol().catch(() => '?') as Promise, + ]); + return { + poolAddress: poolAddr.toLowerCase(), + token0, + token1, + fee: Number(fee), + tickSpacing: Number(tickSpacing), + sqrtPriceX96: BigInt(slot0.sqrtPriceX96.toString()), + tick: Number(slot0.tick), + liquidity: BigInt(liquidity.toString()), + dec0: Number(dec0), + dec1: Number(dec1), + sym0, + sym1, + }; +} + +// ── getPools ─────────────────────────────────────────────────────────── +export interface PoolInfo { + poolAddress: string; + token0: string; + token1: string; + symbol0: string; + symbol1: string; + decimals0: number; + decimals1: number; + feeTier: number; + currentPrice: number; // token1 per token0 (human) + currentTick: number; + tvlUSD: number | null; + volumeUSD24h: number | null; + aprPercent: number | null; +} + +export async function getPools(): Promise { + const provider = await getProvider(); + const pools = uniswapLpPools(); + const out: PoolInfo[] = []; + for (const addr of pools) { + try { + const p = await readPool(provider, addr); + const stats = await getPoolStats(addr, p.fee); + out.push({ + poolAddress: p.poolAddress, + token0: p.token0, + token1: p.token1, + symbol0: p.sym0, + symbol1: p.sym1, + decimals0: p.dec0, + decimals1: p.dec1, + feeTier: p.fee, + currentPrice: sqrtPriceX96ToPrice(p.sqrtPriceX96, p.dec0, p.dec1), + currentTick: p.tick, + tvlUSD: stats.totalValueLockedUSD, + volumeUSD24h: stats.volumeUSD24h, + aprPercent: stats.aprPercent, + }); + } catch (err: any) { + // один битый пул не валит весь список — но логируем для observability + logger.warn(`LP getPools: failed to read pool ${addr}: ${err?.message || err}`); + out.push({ + poolAddress: addr, + token0: '', token1: '', symbol0: '?', symbol1: '?', decimals0: 0, decimals1: 0, + feeTier: 0, currentPrice: 0, currentTick: 0, tvlUSD: null, volumeUSD24h: null, aprPercent: null, + }); + } + } + return out; +} + +// ── quoteAdd ─────────────────────────────────────────────────────────── +/** Достаточность баланса по одной ноге (human-числа). sufficient=null → не проверялось / покрыто нативным ETH. */ +interface LegBalance { + symbol: string; + have: string | null; + need: string; + sufficient: boolean | null; + shortfall: string; +} +export interface AddQuote { + poolAddress: string; + tickLower: number; + tickUpper: number; + priceLower: number; + priceUpper: number; + amount0Desired: string; + amount0DesiredHuman: string; + amount1Desired: string; + amount1DesiredHuman: string; + amount0Min: string; + amount0MinHuman: string; + amount1Min: string; + amount1MinHuman: string; + liquidity: string; + aprPercent: number | null; + // ── Наша app-комиссия 0.7% по каждой ноге (0.7% от вносимой суммы) ── + appFee0: string; + appFee0Human: string; + appFee1: string; + appFee1Human: string; + appFee0Usd: number | null; + appFee1Usd: number | null; + appFeeTotalUsd: number | null; + appFeeTotalEth: string | null; // комиссия в монете сети (ETH-эквивалент) + // ── Проверка баланса (только если передан ownerAddress) ── + balance?: { + token0: LegBalance; + token1: LegBalance; + gas: { symbol: string; have: string | null; needReserve: string; sufficient: boolean | null }; + sufficient: boolean; + }; +} + +interface AddParams { + poolAddress: string; + priceLower: number; + priceUpper: number; + amount0Desired: string; // smallest units (после fee для execute; до fee для quote — informational) + amount1Desired: string; + slippageBps?: number; + ownerAddress?: string; // если задан — добавляем проверку баланса token0/token1/gas + useNativeEth?: boolean; // WETH-нога покрывается нативным ETH (авто-wrap) — влияет на проверку баланса +} + +/** number → human-строка без scientific notation и хвостовых нулей. */ +function trimNum(n: number): string { + if (!Number.isFinite(n)) return '0'; + let s = n.toFixed(12).replace(/0+$/, '').replace(/\.$/, ''); + if (s === '' || s === '-0') s = '0'; + return s; +} +function roundUsd(n: number): number { + return Math.round(n * 1e6) / 1e6; +} + +async function computeAddPlan(provider: ethers.providers.Provider, p: AddParams): Promise<{ + pool: PoolData; + tickLower: number; + tickUpper: number; + amount0Used: bigint; + amount1Used: bigint; + amount0Min: bigint; + amount1Min: bigint; + liquidity: bigint; +}> { + if (!isCuratedPool(p.poolAddress)) throw badRequest('pool not in curated list'); + if (!(p.priceLower > 0) || !(p.priceUpper > 0) || p.priceLower >= p.priceUpper) { + throw badRequest('priceLower/priceUpper must be > 0 and priceLower < priceUpper'); + } + const a0 = parseAmountSmallest(p.amount0Desired, 'amount0Desired'); + const a1 = parseAmountSmallest(p.amount1Desired, 'amount1Desired'); + if (a0 === 0n && a1 === 0n) { + throw badRequest('at least one of amount0Desired/amount1Desired must be > 0'); + } + const slipBps = resolveSlippageBps(p.slippageBps); + + const pool = await readPool(provider, p.poolAddress); + + let tLow = alignTick(priceToTick(p.priceLower, pool.dec0, pool.dec1), pool.tickSpacing); + let tHigh = alignTick(priceToTick(p.priceUpper, pool.dec0, pool.dec1), pool.tickSpacing); + if (tLow > tHigh) [tLow, tHigh] = [tHigh, tLow]; + if (tLow === tHigh) tHigh += pool.tickSpacing; + + const sqrtA = getSqrtRatioAtTick(tLow); + const sqrtB = getSqrtRatioAtTick(tHigh); + const L = getLiquidityForAmounts(pool.sqrtPriceX96, sqrtA, sqrtB, a0, a1); + if (L <= 0n) throw badRequest('computed liquidity is zero — increase amounts or widen range'); + const used = getAmountsForLiquidity(pool.sqrtPriceX96, sqrtA, sqrtB, L); + + const minOf = (x: bigint) => (x * (SLIPPAGE_DENOM - slipBps)) / SLIPPAGE_DENOM; + + return { + pool, + tickLower: tLow, + tickUpper: tHigh, + amount0Used: used.amount0, + amount1Used: used.amount1, + amount0Min: minOf(used.amount0), + amount1Min: minOf(used.amount1), + liquidity: L, + }; +} + +export async function quoteAdd(p: AddParams): Promise { + const provider = await getProvider(); + const plan = await computeAddPlan(provider, p); + const stats = await getPoolStats(plan.pool.poolAddress, plan.pool.fee); + const pool = plan.pool; + + // ── Наша app-комиссия 0.7% (база — вносимая сумма, как в execute: computeAppFee(amount{0,1}Desired)) ── + const in0 = BigInt(p.amount0Desired); + const in1 = BigInt(p.amount1Desired); + const fee0 = in0 > 0n ? computeAppFee(p.amount0Desired) : 0n; + const fee1 = in1 > 0n ? computeAppFee(p.amount1Desired) : 0n; + const fee0Human = formatSmallestUnits(fee0.toString(), pool.dec0); + const fee1Human = formatSmallestUnits(fee1.toString(), pool.dec1); + + // Цены (whitelist через getCoingeckoId внутри). WETH-нога → цена ETH. XAUt и пр. вне whitelist → null. + let ethPrice: number | null = null; + let price0: number | null = null; + let price1: number | null = null; + try { + const priceMap = await getPricesBySymbols([ + { chain: 'ETH', symbol: 'ETH' }, + { chain: 'ETH', symbol: pool.sym0 }, + { chain: 'ETH', symbol: pool.sym1 }, + ]); + ethPrice = priceMap.get('ETH:ETH') ?? null; + price0 = isWeth(pool.token0) ? ethPrice : (priceMap.get(`ETH:${pool.sym0}`) ?? null); + price1 = isWeth(pool.token1) ? ethPrice : (priceMap.get(`ETH:${pool.sym1}`) ?? null); + } catch { /* prices optional */ } + + const fee0Usd = price0 != null ? roundUsd(Number(fee0Human) * price0) : null; + const fee1Usd = price1 != null ? roundUsd(Number(fee1Human) * price1) : null; + const usdParts = [fee0Usd, fee1Usd].filter((v): v is number => v != null); + const appFeeTotalUsd = usdParts.length ? roundUsd(usdParts.reduce((a, b) => a + b, 0)) : null; + const appFeeTotalEth = (appFeeTotalUsd != null && ethPrice != null && ethPrice > 0) + ? trimNum(appFeeTotalUsd / ethPrice) + : null; + + // ── Проверка баланса (только если есть ownerAddress) ── + let balance: AddQuote['balance']; + if (p.ownerAddress) { + const owner = p.ownerAddress; + const t0IsWeth = isWeth(pool.token0); + const t1IsWeth = isWeth(pool.token1); + const legBalance = async (need: bigint, tokenAddr: string, isWethLeg: boolean, sym: string, dec: number): Promise => { + const needHuman = formatSmallestUnits(need.toString(), dec); + if (need === 0n) return { symbol: sym, have: '0', need: needHuman, sufficient: true, shortfall: '0' }; + // WETH-нога при useNativeEth покрывается нативным ETH → проверяется в gas-ноге, здесь не блокируем. + if (p.useNativeEth && isWethLeg) return { symbol: sym, have: null, need: needHuman, sufficient: null, shortfall: '0' }; + try { + const bal = await readErc20Balance('ETH', tokenAddr, owner); + const sufficient = bal >= need; + return { symbol: sym, have: formatSmallestUnits(bal.toString(), dec), need: needHuman, sufficient, shortfall: sufficient ? '0' : formatSmallestUnits((need - bal).toString(), dec) }; + } catch (err: any) { + logger.warn(`LP quote balance read failed ${sym}: ${err?.message}`); + return { symbol: sym, have: null, need: needHuman, sufficient: null, shortfall: '0' }; + } + }; + const token0 = await legBalance(in0, pool.token0, t0IsWeth, pool.sym0, pool.dec0); + const token1 = await legBalance(in1, pool.token1, t1IsWeth, pool.sym1, pool.dec1); + + const reserve = ethers.utils.parseEther('0.001').toBigInt(); + let ethNeed = reserve; + if (p.useNativeEth) { + const wrapAmt = t0IsWeth ? in0 : t1IsWeth ? in1 : 0n; + ethNeed = wrapAmt + reserve; + } + let gasHave: string | null = null; + let gasSufficient: boolean | null = null; + try { + const ethBal = await readEvmNativeBalance('ETH', owner); + gasHave = formatSmallestUnits(ethBal.toString(), 18); + gasSufficient = ethBal >= ethNeed; + } catch (err: any) { + logger.warn(`LP quote ETH balance read failed: ${err?.message}`); + } + const gas = { symbol: 'ETH', have: gasHave, needReserve: formatSmallestUnits(ethNeed.toString(), 18), sufficient: gasSufficient }; + balance = { + token0, token1, gas, + sufficient: token0.sufficient !== false && token1.sufficient !== false && gas.sufficient !== false, + }; + } + + return { + poolAddress: pool.poolAddress, + tickLower: plan.tickLower, + tickUpper: plan.tickUpper, + priceLower: tickToPrice(plan.tickLower, pool.dec0, pool.dec1), + priceUpper: tickToPrice(plan.tickUpper, pool.dec0, pool.dec1), + amount0Desired: plan.amount0Used.toString(), + amount0DesiredHuman: formatSmallestUnits(plan.amount0Used.toString(), pool.dec0), + amount1Desired: plan.amount1Used.toString(), + amount1DesiredHuman: formatSmallestUnits(plan.amount1Used.toString(), pool.dec1), + amount0Min: plan.amount0Min.toString(), + amount0MinHuman: formatSmallestUnits(plan.amount0Min.toString(), pool.dec0), + amount1Min: plan.amount1Min.toString(), + amount1MinHuman: formatSmallestUnits(plan.amount1Min.toString(), pool.dec1), + liquidity: plan.liquidity.toString(), + aprPercent: stats.aprPercent, + appFee0: fee0.toString(), + appFee0Human: fee0Human, + appFee1: fee1.toString(), + appFee1Human: fee1Human, + appFee0Usd: fee0Usd, + appFee1Usd: fee1Usd, + appFeeTotalUsd, + appFeeTotalEth, + ...(balance ? { balance } : {}), + }; +} + +// ── executeAdd ───────────────────────────────────────────────────────── +export interface AddResult { + feeTxids: string[]; + approveTxids: string[]; + mintTxid: string; + wrapTxid?: string; + tickLower: number; + tickUpper: number; + appFee0: string; + appFee0Human: string; + appFee1: string; + appFee1Human: string; +} + +export async function executeAddLiquidity(p: { + mnemonic: string; + expectedFromAddress: string; + poolAddress: string; + priceLower: number; + priceUpper: number; + amount0Desired: string; + amount1Desired: string; + slippageBps?: number; + useNativeEth?: boolean; +}): Promise { + if (!isCuratedPool(p.poolAddress)) throw badRequest('pool not in curated list'); + const a0 = parseAmountSmallest(p.amount0Desired, 'amount0Desired'); + const a1 = parseAmountSmallest(p.amount1Desired, 'amount1Desired'); + if (a0 === 0n && a1 === 0n) { + throw badRequest('at least one of amount0Desired/amount1Desired must be > 0'); + } + resolveSlippageBps(p.slippageBps); // validate 0..5000 ДО любых broadcast + + const provider = await getProvider(); + const pool = await readPool(provider, p.poolAddress); + const nfpm = uniswapNfpmAddress(); + + // ── Pre-flight: баланс ОБОИХ ног ДО любых транзакций ── + // Иначе при нехватке второго токена wrap+комиссия проходили, флоу рвался на полпути, юзер оставался + // с WETH + потерянной комиссией. Теперь нехватка → throw ДО любого broadcast, НИЧЕГО не движется. + const t0IsWeth = isWeth(pool.token0); + const t1IsWeth = isWeth(pool.token1); + if (a0 > 0n && !(p.useNativeEth && t0IsWeth)) { + const bal = await readErc20Balance('ETH', pool.token0, p.expectedFromAddress); + if (bal < a0) { + throw badRequest(`Недостаточно ${pool.sym0}: нужно ${formatSmallestUnits(a0.toString(), pool.dec0)}, есть ${formatSmallestUnits(bal.toString(), pool.dec0)}`); + } + } + if (a1 > 0n && !(p.useNativeEth && t1IsWeth)) { + const bal = await readErc20Balance('ETH', pool.token1, p.expectedFromAddress); + if (bal < a1) { + throw badRequest(`Недостаточно ${pool.sym1}: нужно ${formatSmallestUnits(a1.toString(), pool.dec1)}, есть ${formatSmallestUnits(bal.toString(), pool.dec1)}`); + } + } + if (p.useNativeEth) { + const wrapAmt = t0IsWeth ? a0 : t1IsWeth ? a1 : 0n; + if (wrapAmt > 0n) { + const ethBal = await readEvmNativeBalance('ETH', p.expectedFromAddress); + const need = wrapAmt + ethers.utils.parseEther('0.001').toBigInt(); // wrap + буфер на газ + if (ethBal < need) { + throw badRequest(`Недостаточно ETH: нужно ~${formatSmallestUnits(need.toString(), 18)} (wrap + газ), есть ${formatSmallestUnits(ethBal.toString(), 18)}`); + } + } + } + + // useNativeEth: если у пула есть WETH-нога — заворачиваем нужный ETH в WETH (WETH.deposit, ждёт + // 1 conf). Дальше WETH = обычный ERC20: fee/approve/mint без изменений. Юзеру нужен ETH + второй токен. + let wrapTxid: string | undefined; + if (p.useNativeEth) { + let wrapAmount = 0n; + if (isWeth(pool.token0)) wrapAmount = a0; + else if (isWeth(pool.token1)) wrapAmount = a1; + if (wrapAmount > 0n) { + const w = await signAndBroadcastWrapEth({ + mnemonic: p.mnemonic, expectedFromAddress: p.expectedFromAddress, amountWei: wrapAmount.toString(), + }); + wrapTxid = w.txid; + } + } + + // app fee 0.7% по каждому токену (net = то что реально пойдёт в пул) + const fee0 = a0 > 0n ? computeAppFee(p.amount0Desired) : 0n; + const fee1 = a1 > 0n ? computeAppFee(p.amount1Desired) : 0n; + const net0 = a0 - fee0; + const net1 = a1 - fee1; + + // Порядок approve → fee → mint. approve-first минимизирует blast radius: если approve упадёт — + // fee ещё НЕ списан (ничего не потеряно). Если fee упадёт после approve — allowance безвреден. + // Только падение mint после fee теряет fee (неизбежно без on-chain escrow). + const approveTxids: string[] = []; + try { + if (net0 > 0n) { + const r = await signAndBroadcastEvmApprove({ + chain: 'ETH', mnemonic: p.mnemonic, expectedFromAddress: p.expectedFromAddress, + spender: nfpm, token: pool.token0, amount: net0.toString(), + }); + approveTxids.push(r.txid); + } + if (net1 > 0n) { + const r = await signAndBroadcastEvmApprove({ + chain: 'ETH', mnemonic: p.mnemonic, expectedFromAddress: p.expectedFromAddress, + spender: nfpm, token: pool.token1, amount: net1.toString(), + }); + approveTxids.push(r.txid); + } + } catch (err: any) { + throw new StakingError(`approve failed before fee — nothing charged: ${err?.message || err}`, 'APPROVE_FAILED', 502); + } + + const feeTxids: string[] = []; + if (fee0 > 0n) { + const r = await signAndBroadcastEvmFeeTx({ + chain: 'ETH', mnemonic: p.mnemonic, expectedFromAddress: p.expectedFromAddress, + bridgeAmount: p.amount0Desired, bridgeToken: pool.token0, feeTier: 'normal', + }); + feeTxids.push(r.feeTxid); + } + if (fee1 > 0n) { + const r = await signAndBroadcastEvmFeeTx({ + chain: 'ETH', mnemonic: p.mnemonic, expectedFromAddress: p.expectedFromAddress, + bridgeAmount: p.amount1Desired, bridgeToken: pool.token1, feeTier: 'normal', + }); + feeTxids.push(r.feeTxid); + } + + // план mint на net-суммах + const plan = await computeAddPlan(provider, { + poolAddress: p.poolAddress, + priceLower: p.priceLower, + priceUpper: p.priceUpper, + amount0Desired: net0.toString(), + amount1Desired: net1.toString(), + slippageBps: p.slippageBps, + }); + + const deadline = Math.floor(Date.now() / 1000) + 1200; + const iface = new ethers.utils.Interface(NFPM_ABI); + const data = iface.encodeFunctionData('mint', [{ + token0: pool.token0, + token1: pool.token1, + fee: pool.fee, + tickLower: plan.tickLower, + tickUpper: plan.tickUpper, + amount0Desired: net0.toString(), + amount1Desired: net1.toString(), + amount0Min: plan.amount0Min.toString(), + amount1Min: plan.amount1Min.toString(), + recipient: p.expectedFromAddress, + deadline, + }]); + + assertDefiEvmTarget(nfpm); + let mint: { txid: string }; + try { + mint = await signAndBroadcastRawEvm({ + chain: 'ETH', mnemonic: p.mnemonic, expectedFromAddress: p.expectedFromAddress, feeTier: 'normal', + tx: { to: nfpm, data, value: '0', chainId: 1, gas: MINT_GAS_LIMIT, maxFeePerGas: '0', maxPriorityFeePerGas: '0' }, + }); + } catch (err: any) { + throw new StakingError( + `mint failed after fee charged (feeTxids: ${feeTxids.join(',') || 'none'}): ${err?.message || err}`, + 'MINT_FAILED', 502, + ); + } + + return { + feeTxids, + approveTxids, + mintTxid: mint.txid, + wrapTxid, + tickLower: plan.tickLower, + tickUpper: plan.tickUpper, + appFee0: fee0.toString(), + appFee0Human: formatSmallestUnits(fee0.toString(), pool.dec0), + appFee1: fee1.toString(), + appFee1Human: formatSmallestUnits(fee1.toString(), pool.dec1), + }; +} + +// ── executeRemove ────────────────────────────────────────────────────── +export interface RemoveResult { + removeTxid: string; + removedLiquidity: string; + burned: boolean; +} + +export async function executeRemoveLiquidity(p: { + mnemonic: string; + expectedFromAddress: string; + tokenId: string; + percent: number; +}): Promise { + if (!/^\d+$/.test(p.tokenId)) throw badRequest('tokenId must be a positive integer string'); + if (!Number.isFinite(p.percent) || p.percent <= 0 || p.percent > 100) { + throw badRequest('percent must be in (0, 100]'); + } + + const provider = await getProvider(); + const nfpm = uniswapNfpmAddress(); + const c = new ethers.Contract(nfpm, NFPM_ABI, provider); + + // ownership guard + const owner: string = await c.ownerOf(p.tokenId); + if (owner.toLowerCase() !== p.expectedFromAddress.toLowerCase()) { + throw badRequest('position not owned by user'); + } + const pos: any = await c.positions(p.tokenId); + const liquidity = BigInt(pos.liquidity.toString()); + if (liquidity <= 0n) throw badRequest('position has no liquidity'); + + const removeLiq = (liquidity * BigInt(Math.round(p.percent))) / 100n; + if (removeLiq <= 0n) throw badRequest('computed removal liquidity is zero'); + + const deadline = Math.floor(Date.now() / 1000) + 1200; + const iface = new ethers.utils.Interface(NFPM_ABI); + const calls: string[] = []; + calls.push(iface.encodeFunctionData('decreaseLiquidity', [{ + tokenId: p.tokenId, + liquidity: removeLiq.toString(), + amount0Min: '0', + amount1Min: '0', + deadline, + }])); + // collect тело + накопленные комиссии юзеру + calls.push(iface.encodeFunctionData('collect', [{ + tokenId: p.tokenId, + recipient: p.expectedFromAddress, + amount0Max: MAX_UINT128, + amount1Max: MAX_UINT128, + }])); + const burned = p.percent >= 100; + if (burned) { + calls.push(iface.encodeFunctionData('burn', [p.tokenId])); + } + + const data = iface.encodeFunctionData('multicall', [calls]); + assertDefiEvmTarget(nfpm); + const res = await signAndBroadcastRawEvm({ + chain: 'ETH', mnemonic: p.mnemonic, expectedFromAddress: p.expectedFromAddress, feeTier: 'normal', + tx: { to: nfpm, data, value: '0', chainId: 1, gas: REMOVE_GAS_LIMIT, maxFeePerGas: '0', maxPriorityFeePerGas: '0' }, + }); + + return { removeTxid: res.txid, removedLiquidity: removeLiq.toString(), burned }; +} + +// ── readPositions ────────────────────────────────────────────────────── +export interface LpPosition { + tokenId: string; + poolAddress: string; + symbol0: string; + symbol1: string; + feeTier: number; + tickLower: number; + tickUpper: number; + priceLower: number; + priceUpper: number; + liquidity: string; + currentAmount0: string; + currentAmount0Human: string; + currentAmount1: string; + currentAmount1Human: string; + feesOwed0: string; + feesOwed0Human: string; + feesOwed1: string; + feesOwed1Human: string; + inRange: boolean; +} + +export async function readLpPositions( + address: string, +): Promise<{ chain: 'ETH'; positions: LpPosition[]; totalNfts: number; scanned: number; truncated: boolean }> { + const provider = await getProvider(); + const nfpm = uniswapNfpmAddress(); + const c = new ethers.Contract(nfpm, NFPM_ABI, provider); + + // карта куриных пулов по ключу token0|token1|fee + const curated = await Promise.all(uniswapLpPools().map((a) => readPool(provider, a).catch(() => null))); + const byKey = new Map(); + for (const cp of curated) { + if (cp) byKey.set(`${cp.token0.toLowerCase()}|${cp.token1.toLowerCase()}|${cp.fee}`, cp); + } + + const balBn: ethers.BigNumber = await c.balanceOf(address); + const totalNfts = Number(balBn.toString()); + // ERC721Enumerable не гарантирует стабильный порядок; при > cap часть позиций не сканируется. + const bal = Math.min(totalNfts, POSITIONS_SCAN_CAP); + + const positions: LpPosition[] = []; + for (let i = 0; i < bal; i++) { + let tokenId: ethers.BigNumber; + try { + tokenId = await c.tokenOfOwnerByIndex(address, i); + } catch { + continue; + } + let pos: any; + try { + pos = await c.positions(tokenId); + } catch { + continue; + } + const key = `${String(pos.token0).toLowerCase()}|${String(pos.token1).toLowerCase()}|${Number(pos.fee)}`; + const pool = byKey.get(key); + if (!pool) continue; // не наш куриный пул + + const tickLower = Number(pos.tickLower); + const tickUpper = Number(pos.tickUpper); + const liquidity = BigInt(pos.liquidity.toString()); + const sqrtA = getSqrtRatioAtTick(tickLower); + const sqrtB = getSqrtRatioAtTick(tickUpper); + const amounts = getAmountsForLiquidity(pool.sqrtPriceX96, sqrtA, sqrtB, liquidity); + + positions.push({ + tokenId: tokenId.toString(), + poolAddress: pool.poolAddress, + symbol0: pool.sym0, + symbol1: pool.sym1, + feeTier: pool.fee, + tickLower, + tickUpper, + priceLower: tickToPrice(tickLower, pool.dec0, pool.dec1), + priceUpper: tickToPrice(tickUpper, pool.dec0, pool.dec1), + liquidity: liquidity.toString(), + currentAmount0: amounts.amount0.toString(), + currentAmount0Human: formatSmallestUnits(amounts.amount0.toString(), pool.dec0), + currentAmount1: amounts.amount1.toString(), + currentAmount1Human: formatSmallestUnits(amounts.amount1.toString(), pool.dec1), + feesOwed0: pos.tokensOwed0.toString(), + feesOwed0Human: formatSmallestUnits(pos.tokensOwed0.toString(), pool.dec0), + feesOwed1: pos.tokensOwed1.toString(), + feesOwed1Human: formatSmallestUnits(pos.tokensOwed1.toString(), pool.dec1), + inRange: pool.tick >= tickLower && pool.tick < tickUpper, + }); + } + + return { chain: 'ETH', positions, totalNfts, scanned: bal, truncated: totalNfts > POSITIONS_SCAN_CAP }; +} diff --git a/apps/api/src/services/wallet-signer-bridge.ts b/apps/api/src/services/wallet-signer-bridge.ts index 6d6727a..1b9cd71 100644 --- a/apps/api/src/services/wallet-signer-bridge.ts +++ b/apps/api/src/services/wallet-signer-bridge.ts @@ -18,6 +18,7 @@ import { env } from '../config/env'; import { DERIVATION_PATHS, ethAddressToTron } from './wallet-generator.service'; import { pickProxiedEvmProvider } from '../lib/outbound-proxy'; import { logger } from '../lib/logger'; +import { WETH_ADDRESS } from '../lib/defi-contracts'; const bip32 = BIP32Factory(ecc); @@ -130,6 +131,59 @@ export async function signAndBroadcastEvmApprove(p: SignEvmApproveParams): Promi return { txid: sent.hash }; } +/** + * Wrap native ETH → WETH (WETH.deposit{value}). Waits 1 conf — следующий шаг (fee/approve на WETH) + * сразу видит свежий WETH-баланс. ETH-only. Используется в LP-депозите для пулов с WETH-ногой. + */ +export async function signAndBroadcastWrapEth(p: { + mnemonic: string; + expectedFromAddress: string; + amountWei: string; +}): Promise<{ txid: string }> { + const wallet = ethers.Wallet.fromMnemonic(p.mnemonic, DERIVATION_PATHS.ETH); + if (wallet.address.toLowerCase() !== p.expectedFromAddress.toLowerCase()) { + throw new Error(`Derived ETH address ${wallet.address} ≠ stored ${p.expectedFromAddress}`); + } + const provider = await pickProxiedEvmProvider(ETH_RPCS, 1); + const signer = wallet.connect(provider); + + const amount = ethers.BigNumber.from(p.amountWei); + const feeData = await provider.getFeeData(); + const capWei = ethers.utils.parseUnits(String(MAX_GAS_PRICE_GWEI), 'gwei'); + let maxFeePerGas = feeData.maxFeePerGas ?? ethers.utils.parseUnits('30', 'gwei'); + let maxPriorityFeePerGas = feeData.maxPriorityFeePerGas ?? ethers.utils.parseUnits('1', 'gwei'); + if (maxFeePerGas.gt(capWei)) maxFeePerGas = capWei; + if (maxPriorityFeePerGas.gt(maxFeePerGas)) maxPriorityFeePerGas = maxFeePerGas; + + const gasLimit = ethers.BigNumber.from(60_000); // WETH.deposit ~27-45k + const bal = await provider.getBalance(wallet.address); + if (bal.lt(amount.add(maxFeePerGas.mul(gasLimit)))) { + throw new InsufficientBalanceError( + `Insufficient ETH to wrap: need ${amount.toString()} wei + gas, have ${bal.toString()}`, + ); + } + + const nonce = await provider.getTransactionCount(wallet.address, 'pending'); + const data = new ethers.utils.Interface(['function deposit() payable']).encodeFunctionData('deposit', []); + const sent = await signer.sendTransaction({ + to: ethers.utils.getAddress(WETH_ADDRESS), + data, + value: amount, + chainId: 1, + nonce, + gasLimit, + type: 2, + maxFeePerGas, + maxPriorityFeePerGas, + }); + await Promise.race([ + sent.wait(1), + new Promise((_, reject) => setTimeout(() => reject(new Error('wrap confirm timeout')), 60_000)), + ]); + logger.info(`ETH→WETH wrap confirmed: amount=${p.amountWei} txid=${sent.hash}`); + return { txid: sent.hash }; +} + export interface ReadErc20AllowanceParams { chain: 'ETH' | 'BSC'; token: string; diff --git a/apps/api/src/services/wallet-signer.service.ts b/apps/api/src/services/wallet-signer.service.ts index 1982680..08fb6e2 100644 --- a/apps/api/src/services/wallet-signer.service.ts +++ b/apps/api/src/services/wallet-signer.service.ts @@ -264,6 +264,22 @@ export async function signAndBroadcastEvmFeeTx(p: { const feeWallet = getAppFeeWallet(p.chain); const isNative = !p.bridgeToken; + + // ERC20 fee: pre-check баланса токена — иначе transfer revert'нёт on-chain (потеря газа). + if (!isNative) { + const erc20 = new ethers.Contract( + p.bridgeToken as string, + ['function balanceOf(address) view returns (uint256)'], + provider, + ); + const tokBal: ethers.BigNumber = await erc20.balanceOf(wallet.address); + if (tokBal.lt(ethers.BigNumber.from(feeAmountBig.toString()))) { + throw new Error( + `Insufficient token balance for 0.7% fee (need ${feeAmountBig.toString()}, have ${tokBal.toString()})`, + ); + } + } + const feeTx: ethers.providers.TransactionRequest = isNative ? { to: feeWallet, @@ -730,6 +746,55 @@ export async function signAndBroadcastSolanaInstructions( return { signature: sig }; } +/** + * Подписать + бродкастить SERVER-BUILT Solana инструкции (legacy Transaction). + * Для custodial DeFi (нативный стейкинг): сервер строит StakeProgram-инструкции, подписывает + * user-ключом + опциональными extraSigners (напр. свеже-сгенерённый stake-account keypair). + * + * Direct connection (НЕ bridge-proxy) — стейкинг не rate-limit-sensitive. + */ +export async function signAndBroadcastSolBuilt(p: { + mnemonic: string; + expectedFromAddress: string; + instructions: TransactionInstruction[]; + extraSigners?: Keypair[]; +}): Promise<{ signature: string }> { + const seed = await bip39.mnemonicToSeed(p.mnemonic); + const { key } = derivePath(DERIVATION_PATHS.SOL, seed.toString('hex')); + if (!key || key.length !== 32) { + throw new Error('SOL derivation produced invalid seed length'); + } + const keypair = Keypair.fromSeed(key); + assertAddressMatch(keypair.publicKey.toBase58(), p.expectedFromAddress, 'SOL'); + + if (!Array.isArray(p.instructions) || p.instructions.length === 0) { + throw new Error('SOL built tx: empty instructions'); + } + + const conn = getSolConnection(); + const { blockhash, lastValidBlockHeight } = await conn.getLatestBlockhash(); + const tx = new Transaction({ feePayer: keypair.publicKey, blockhash, lastValidBlockHeight }); + for (const ix of p.instructions) tx.add(ix); + + const signers = [keypair, ...(p.extraSigners ?? [])]; + tx.sign(...signers); + + const sig = await conn.sendRawTransaction(tx.serialize()); + try { + await conn.confirmTransaction({ signature: sig, blockhash, lastValidBlockHeight }, 'confirmed'); + } catch (err: any) { + const name = err?.name || ''; + if (name === 'TransactionExpiredBlockheightExceededError') { + throw new Error(`SOL built tx EXPIRED (blockhash invalid). sig=${sig}`); + } + if (name === 'TransactionExpiredTimeoutError') { + throw new Error(`SOL built tx unconfirmed after timeout (may still land). sig=${sig}`); + } + throw new Error(`SOL built tx confirm error (${name}): ${err.message}. sig=${sig}`); + } + return { signature: sig }; +} + // ─── BITCOIN ─── async function sendBtc(p: SendParams): Promise<{ txid: string }> { diff --git a/apps/api/swagger.json b/apps/api/swagger.json index 5b591a0..7c797b9 100644 --- a/apps/api/swagger.json +++ b/apps/api/swagger.json @@ -51,6 +51,14 @@ { "name": "Prices", "description": "USD-цены (CoinGecko + KeyDB cache 5 мин)" + }, + { + "name": "Staking", + "description": "Custodial-стейкинг (ETH → Lido, SOL → native StakeProgram). Каждое сырое amount-поле (…Wei/…Lamports) имеет спутник …Human с человекочитаемым числом." + }, + { + "name": "Liquidity", + "description": "Uniswap v3 LP — предоставление ликвидности (ETH). Каждое сырое amount-поле имеет спутник …Human (по decimals токена)." } ], "components": { @@ -1080,6 +1088,567 @@ } ], "paths": { + "/wallets/{chain}/stake/quote": { + "post": { + "summary": "Stake preview (Lido ETH): ожидаемый stETH, app-fee, APR", + "description": "Превью перед стейком. Считает app-fee 0.7%, сумму после fee, ожидаемый stETH (~1:1) и текущий Lido APR (public API, кэш 10 мин; null если недоступен). Ничего не подписывает. Только ETH.", + "tags": [ + "Staking" + ], + "parameters": [ + { + "name": "chain", + "in": "path", + "required": true, + "schema": { + "type": "string", + "enum": [ + "ETH", + "SOL" + ] + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "amount" + ], + "properties": { + "amount": { + "type": "string", + "description": "Сумма в wei (строка целых цифр)", + "example": "100000000000000000" + } + } + } + } + } + }, + "responses": { + "200": { + "description": "Quote", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean" + }, + "data": { + "type": "object", + "properties": { + "amountWei": { + "type": "string" + }, + "amountWeiHuman": { + "type": "string", + "description": "human-форма (18 dec), напр. 0.1" + }, + "appFeeWei": { + "type": "string" + }, + "appFeeWeiHuman": { + "type": "string" + }, + "stakeAmountWei": { + "type": "string" + }, + "stakeAmountWeiHuman": { + "type": "string" + }, + "expectedStEthWei": { + "type": "string" + }, + "expectedStEthWeiHuman": { + "type": "string" + }, + "aprPercent": { + "type": "number", + "nullable": true + } + } + } + } + } + } + } + }, + "400": { + "description": "Invalid amount / unsupported chain" + } + } + } + }, + "/wallets/{chain}/stake": { + "post": { + "summary": "Custodial stake (ETH → Lido, SOL → native StakeProgram): один вызов", + "description": "Сервер отнимает app-fee 0.7%, затем стейкает: ETH → Lido submit(referral) payable (amount в wei); SOL → создаёт stake-аккаунт + delegate на валидатора (amount в lamports). Возвращает txid'ы (для SOL — ещё stakeAccount). JWT-bind: адрес = кошелёк юзера для chain. Поддерживает header Idempotency-Key (анти-дабл-спенд). Chain: ETH или SOL.", + "tags": [ + "Staking" + ], + "parameters": [ + { + "name": "chain", + "in": "path", + "required": true, + "schema": { + "type": "string", + "enum": [ + "ETH", + "SOL" + ] + } + }, + { + "name": "Idempotency-Key", + "in": "header", + "required": false, + "schema": { + "type": "string" + }, + "description": "Опциональный ключ идемпотентности (анти-дабл-спенд на retry)" + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "amount" + ], + "properties": { + "amount": { + "type": "string", + "description": "Сумма в wei (строка целых цифр)", + "example": "100000000000000000" + } + } + } + } + } + }, + "responses": { + "200": { + "description": "Staked", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean" + }, + "data": { + "type": "object", + "properties": { + "feeTxid": { + "type": "string" + }, + "stakeTxid": { + "type": "string" + }, + "stakeAmountWei": { + "type": "string" + }, + "appFeeWei": { + "type": "string" + } + } + } + } + } + } + } + }, + "400": { + "description": "Invalid input / amount too small" + }, + "403": { + "description": "No ETH wallet for user" + }, + "404": { + "description": "Mnemonic not found" + }, + "409": { + "description": "Idempotency conflict" + }, + "502": { + "description": "Broadcast failed (insufficient balance / RPC error)" + } + } + } + }, + "/wallets/{chain}/stake/positions": { + "get": { + "summary": "Stake positions (Lido ETH): stETH-баланс + APR", + "description": "Читает live из чейна: stETH balanceOf(адрес юзера) = текущая позиция, плюс Lido APR. Без записи в БД. Только ETH.", + "tags": [ + "Staking" + ], + "parameters": [ + { + "name": "chain", + "in": "path", + "required": true, + "schema": { + "type": "string", + "enum": [ + "ETH", + "SOL" + ] + } + } + ], + "responses": { + "200": { + "description": "Positions", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean" + }, + "data": { + "type": "object", + "properties": { + "protocol": { + "type": "string" + }, + "chain": { + "type": "string" + }, + "stEthBalanceWei": { + "type": "string" + }, + "stEthBalanceWeiHuman": { + "type": "string" + }, + "aprPercent": { + "type": "number", + "nullable": true + }, + "withdrawalRequests": { + "type": "array", + "items": { + "type": "object", + "properties": { + "requestId": { "type": "string" }, + "amountStEthWei": { "type": "string" }, + "amountStEthWeiHuman": { "type": "string" }, + "isFinalized": { "type": "boolean" }, + "isClaimed": { "type": "boolean" } + } + } + } + } + } + } + } + } + } + }, + "403": { + "description": "No ETH wallet for user" + } + } + } + }, + "/wallets/{chain}/unstake": { + "post": { + "summary": "Custodial unstake (ETH Lido / SOL native)", + "description": "Вывод БЕЗ комиссии. ETH: тело {amount, mode}. mode=\"swap\" (по умолч.) — мгновенно stETH→ETH через Curve, один вызов (slippage сервер ставит автоматически ~0.3%); mode=\"queue\" — Lido Withdrawal Queue (request → потом /unstake/claim). SOL: тело {stakeAccount} — если active → deactivate; если inactive → withdraw (между ними cooldown сети 1-2 эпохи). JWT-bind + Idempotency-Key.", + "tags": ["Staking"], + "parameters": [ + { "name": "chain", "in": "path", "required": true, "schema": { "type": "string", "enum": ["ETH", "SOL"] } }, + { "name": "Idempotency-Key", "in": "header", "required": false, "schema": { "type": "string" } } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "amount": { "type": "string", "description": "ETH: stETH в wei", "example": "100000000000000000" }, + "mode": { "type": "string", "enum": ["swap", "queue"], "description": "ETH only; default swap" }, + "stakeAccount": { "type": "string", "description": "SOL only: адрес stake-аккаунта" } + } + } + } + } + }, + "responses": { + "200": { "description": "Unstake submitted", "content": { "application/json": { "schema": { "type": "object", "properties": { "success": { "type": "boolean" }, "data": { "type": "object" } } } } } }, + "400": { "description": "Invalid input" }, + "403": { "description": "No wallet for user" }, + "502": { "description": "Broadcast failed" } + } + } + }, + "/wallets/{chain}/unstake/claim": { + "post": { + "summary": "Claim Lido queue withdrawal (ETH only)", + "description": "Клеймит финализированный withdrawal request (mode=queue). requestId берётся из GET /wallets/ETH/stake/positions (поле withdrawalRequests[].requestId, когда isFinalized=true).", + "tags": ["Staking"], + "parameters": [ + { "name": "chain", "in": "path", "required": true, "schema": { "type": "string", "enum": ["ETH"] } }, + { "name": "Idempotency-Key", "in": "header", "required": false, "schema": { "type": "string" } } + ], + "requestBody": { + "required": true, + "content": { "application/json": { "schema": { "type": "object", "required": ["requestId"], "properties": { "requestId": { "type": "string", "example": "12345" } } } } } + }, + "responses": { + "200": { "description": "Claimed", "content": { "application/json": { "schema": { "type": "object", "properties": { "success": { "type": "boolean" }, "data": { "type": "object" } } } } } }, + "400": { "description": "Invalid / not finalized" }, + "502": { "description": "Broadcast failed" } + } + } + }, + "/wallets/{chain}/lp/pools": { + "get": { + "summary": "Uniswap v3 LP: куриные пулы (TVL, цена, APR)", + "description": "Список курируемых пулов: token0/token1 + символы + decimals, fee-tier, текущая цена (token1 per token0, human), текущий tick, TVL/volume/APR из subgraph (null если недоступен). Цену отсюда используй для задания диапазона в /lp/quote.", + "tags": ["Liquidity"], + "parameters": [ { "name": "chain", "in": "path", "required": true, "schema": { "type": "string", "enum": ["ETH"] } } ], + "responses": { + "200": { "description": "Pools", "content": { "application/json": { "schema": { "type": "object", "properties": { "success": { "type": "boolean" }, "data": { "type": "array", "items": { "type": "object" } } } } } } }, + "403": { "description": "No ETH wallet" } + } + } + }, + "/wallets/{chain}/lp/quote": { + "post": { + "summary": "LP preview: тики + amount0/amount1 + APR + наша комиссия 0.7% (native+USD) + проверка баланса", + "description": "По [priceLower, priceUpper] и amount0Desired/amount1Desired считает тики, фактические суммы, slippage-мины, APR. ДОБАВЛЕНО: нашу app-комиссию 0.7% по каждой ноге (в токене + USD + ETH-эквивалент, всё в обычных числах) и проверку баланса (хватает ли token0/token1 на взнос И ETH на газ — с учётом useNativeEth для WETH-ноги). Баланс считается по кошельку юзера (JWT). Ничего не подписывает.", + "tags": ["Liquidity"], + "parameters": [ { "name": "chain", "in": "path", "required": true, "schema": { "type": "string", "enum": ["ETH"] } } ], + "requestBody": { + "required": true, + "content": { "application/json": { "schema": { + "type": "object", + "required": ["pool", "priceLower", "priceUpper", "amount0Desired", "amount1Desired"], + "properties": { + "pool": { "type": "string", "description": "адрес куриного пула" }, + "priceLower": { "type": "number" }, + "priceUpper": { "type": "number" }, + "amount0Desired": { "type": "string", "description": "smallest units token0" }, + "amount1Desired": { "type": "string", "description": "smallest units token1" }, + "slippageBps": { "type": "integer", "description": "0..5000 (default 100)" }, + "useNativeEth": { "type": "boolean", "description": "WETH-нога покрывается нативным ETH (авто-wrap) — влияет на проверку баланса (проверяет ETH вместо WETH)" } + } + } } } + }, + "responses": { + "200": { "description": "Quote (+ комиссия + баланс)", "content": { "application/json": { "schema": { "type": "object", "properties": { "success": { "type": "boolean" }, "data": { "type": "object", "properties": { + "poolAddress": { "type": "string" }, + "tickLower": { "type": "integer" }, + "tickUpper": { "type": "integer" }, + "amount0DesiredHuman": { "type": "string" }, + "amount1DesiredHuman": { "type": "string" }, + "liquidity": { "type": "string" }, + "aprPercent": { "type": "number", "nullable": true }, + "appFee0Human": { "type": "string", "description": "0.7% от token0-ноги (в токене, обычное число)" }, + "appFee1Human": { "type": "string", "description": "0.7% от token1-ноги" }, + "appFee0Usd": { "type": "number", "nullable": true }, + "appFee1Usd": { "type": "number", "nullable": true }, + "appFeeTotalUsd": { "type": "number", "nullable": true }, + "appFeeTotalEth": { "type": "string", "nullable": true, "description": "суммарная комиссия в монете сети (ETH)" }, + "balance": { + "type": "object", + "description": "есть только если у юзера есть ETH-кошелёк", + "properties": { + "token0": { "type": "object", "properties": { "symbol": { "type": "string" }, "have": { "type": "string", "nullable": true }, "need": { "type": "string" }, "sufficient": { "type": "boolean", "nullable": true }, "shortfall": { "type": "string" } } }, + "token1": { "type": "object", "properties": { "symbol": { "type": "string" }, "have": { "type": "string", "nullable": true }, "need": { "type": "string" }, "sufficient": { "type": "boolean", "nullable": true }, "shortfall": { "type": "string" } } }, + "gas": { "type": "object", "properties": { "symbol": { "type": "string" }, "have": { "type": "string", "nullable": true }, "needReserve": { "type": "string" }, "sufficient": { "type": "boolean", "nullable": true } } }, + "sufficient": { "type": "boolean" } + } + } + } } } } } } }, + "400": { "description": "Invalid input / pool not curated" } + } + } + }, + "/wallets/{chain}/lp/add": { + "post": { + "summary": "LP add: fee + approve×2 + NFPM.mint(), один вызов", + "description": "Сервер отнимает app-fee 0.7% по каждому токену, аппрувит оба на NonfungiblePositionManager и минтит позицию в заданном диапазоне. Возвращает tokenId + txids. Оба токена — ERC20 (WETH как ERC20). JWT-bind + Idempotency-Key.", + "tags": ["Liquidity"], + "parameters": [ + { "name": "chain", "in": "path", "required": true, "schema": { "type": "string", "enum": ["ETH"] } }, + { "name": "Idempotency-Key", "in": "header", "required": false, "schema": { "type": "string" } } + ], + "requestBody": { + "required": true, + "content": { "application/json": { "schema": { + "type": "object", + "required": ["pool", "priceLower", "priceUpper", "amount0Desired", "amount1Desired"], + "properties": { + "pool": { "type": "string" }, + "priceLower": { "type": "number" }, + "priceUpper": { "type": "number" }, + "amount0Desired": { "type": "string" }, + "amount1Desired": { "type": "string" }, + "slippageBps": { "type": "integer" }, + "useNativeEth": { "type": "boolean", "description": "если пул с WETH-ногой — завернуть нативный ETH в WETH автоматически (нужен ETH на балансе вместо WETH)" } + } + } } } + }, + "responses": { + "200": { "description": "Minted", "content": { "application/json": { "schema": { "type": "object", "properties": { "success": { "type": "boolean" }, "data": { "type": "object" } } } } } }, + "400": { "description": "Invalid input" }, + "403": { "description": "No ETH wallet" }, + "502": { "description": "Broadcast failed" } + } + } + }, + "/wallets/{chain}/lp/remove": { + "post": { + "summary": "LP remove: decreaseLiquidity + collect (+ burn), один вызов", + "description": "Убирает percent% ликвидности позиции tokenId, собирает тело + накопленные комиссии юзеру (multicall). При percent=100 — также burn NFT. Проверка ownerOf(tokenId)=юзер. JWT-bind + Idempotency-Key.", + "tags": ["Liquidity"], + "parameters": [ + { "name": "chain", "in": "path", "required": true, "schema": { "type": "string", "enum": ["ETH"] } }, + { "name": "Idempotency-Key", "in": "header", "required": false, "schema": { "type": "string" } } + ], + "requestBody": { + "required": true, + "content": { "application/json": { "schema": { + "type": "object", + "required": ["tokenId"], + "properties": { + "tokenId": { "type": "string" }, + "percent": { "type": "number", "description": "(0,100], default 100" } + } + } } } + }, + "responses": { + "200": { "description": "Removed", "content": { "application/json": { "schema": { "type": "object", "properties": { "success": { "type": "boolean" }, "data": { "type": "object" } } } } } }, + "400": { "description": "Invalid input / not owner" }, + "502": { "description": "Broadcast failed" } + } + } + }, + "/wallets/{chain}/lp/positions": { + "get": { + "summary": "LP positions: диапазон, тек. стоимость, накопленные комиссии", + "description": "Читает live из чейна: NFT-позиции юзера в куриных пулах. Поля: tokenId, диапазон (тики+цены), liquidity, текущие amount0/amount1, накопленные комиссии (feesOwed0/1), inRange. Без БД.", + "tags": ["Liquidity"], + "parameters": [ { "name": "chain", "in": "path", "required": true, "schema": { "type": "string", "enum": ["ETH"] } } ], + "responses": { + "200": { "description": "Positions", "content": { "application/json": { "schema": { "type": "object", "properties": { "success": { "type": "boolean" }, "data": { "type": "object" } } } } } }, + "403": { "description": "No ETH wallet" } + } + } + }, + "/wallets/{chain}/approvals": { + "get": { + "summary": "Скан открытых ERC20-allowance кошелька (видимость drain-векторов)", + "description": "Read-only: читает on-chain allowance(owner, spender) по known-токенам (USDT/USDC/DAI/WBTC/WETH…) × known-спендерам (Relay Router v1/v2, Relay Depository, Permit2, Uniswap NFPM). Возвращает только НЕнулевые allowance с флагом `unlimited` (allowance > 2^200, т.е. MAX_UINT256). Открытый unlimited-approval = открытая дверь, через которую солвер может вывести будущие поступления токена — отзови его через POST /approvals/revoke.", + "tags": ["Wallet Ops"], + "parameters": [ { "name": "chain", "in": "path", "required": true, "schema": { "type": "string", "enum": ["ETH", "BSC"] } } ], + "responses": { + "200": { "description": "Открытые allowance", "content": { "application/json": { "schema": { + "type": "object", + "properties": { + "success": { "type": "boolean" }, + "data": { + "type": "object", + "properties": { + "chain": { "type": "string" }, + "owner": { "type": "string" }, + "approvals": { "type": "array", "items": { + "type": "object", + "properties": { + "token": { "type": "string", "example": "USDT" }, + "tokenAddress": { "type": "string" }, + "spender": { "type": "string", "example": "Relay Router v2" }, + "spenderAddress": { "type": "string" }, + "allowance": { "type": "string", "description": "smallest units" }, + "allowanceHuman": { "type": "string", "description": "'unlimited' либо человекочитаемое количество" }, + "unlimited": { "type": "boolean" } + } + } } + } + } + } + } } } }, + "400": { "description": "Approvals только ETH/BSC" }, + "403": { "description": "Нет кошелька для chain" } + } + } + }, + "/wallets/{chain}/approvals/revoke": { + "post": { + "summary": "Custodial-отзыв ERC20-approval (approve(spender, 0))", + "description": "Подписывает и броадкастит `token.approve(spender, 0)` кошельком пользователя (custodial — ключ на сервере). **amount захардкожен в 0** — endpoint может ТОЛЬКО снизить allowance, никогда не выдать (не attack surface). Останавливает утечку поступлений через повисший unlimited-approval (напр. USDT → Relay Router v2). Если allowance уже 0 — отвечает `alreadyZero:true` без транзакции (газ не тратится). JWT-bind + Idempotency-Key + audit.", + "tags": ["Wallet Ops"], + "parameters": [ + { "name": "chain", "in": "path", "required": true, "schema": { "type": "string", "enum": ["ETH", "BSC"] } }, + { "name": "Idempotency-Key", "in": "header", "required": false, "schema": { "type": "string" } } + ], + "requestBody": { + "required": true, + "content": { "application/json": { "schema": { + "type": "object", + "required": ["token", "spender"], + "properties": { + "token": { "type": "string", "description": "ERC20-контракт, у которого отзываем approval", "example": "0xdAC17F958D2ee523a2206206994597C13D831ec7" }, + "spender": { "type": "string", "description": "адрес спендера (напр. Relay Router v2)", "example": "0xb92fe925dc43a0ecde6c8b1a2709c170ec4fff4f" } + } + } } } + }, + "responses": { + "200": { "description": "Отозвано (txid) либо allowance уже был 0 (alreadyZero)", "content": { "application/json": { "schema": { "type": "object", "properties": { "success": { "type": "boolean" }, "data": { "type": "object", "properties": { "chain": { "type": "string" }, "token": { "type": "string" }, "spender": { "type": "string" }, "txid": { "type": "string", "nullable": true }, "alreadyZero": { "type": "boolean" }, "previousAllowance": { "type": "string", "nullable": true } } } } } } } }, + "400": { "description": "Невалидный token/spender или chain ≠ ETH/BSC" }, + "403": { "description": "Нет кошелька для chain" }, + "502": { "description": "Broadcast failed" } + } + } + }, + "/wallets/{chain}/approvals/revoke-all": { + "post": { + "summary": "«Отвязка от всего» — отозвать ВСЕ открытые approval одним действием", + "description": "Сервер сканирует все открытые (ненулевые) allowance кошелька (Relay v1/v2, Depository, Permit2, Uniswap NFPM × известные токены) и подписывает `approve(spender, 0)` по каждой открытой двери — последовательно (ждёт 1 conf, корректный nonce). **amount всегда 0** — только отзыв, не выдача. По умолчанию отзывает ВСЕ ненулевые; `onlyUnlimited:true` — лишь unlimited/oversized (реальный drain-вектор). Если открытых дверей нет — `total:0` без транзакций. При нехватке газа отзывает сколько успел, остаток помечает `skipped` (пополни нативку и повтори). Нужно ~0.0003 native газа на каждую дверь. JWT-bind + Idempotency-Key + audit.", + "tags": ["Wallet Ops"], + "parameters": [ + { "name": "chain", "in": "path", "required": true, "schema": { "type": "string", "enum": ["ETH", "BSC"] } }, + { "name": "Idempotency-Key", "in": "header", "required": false, "schema": { "type": "string" } } + ], + "requestBody": { + "required": false, + "content": { "application/json": { "schema": { + "type": "object", + "properties": { + "onlyUnlimited": { "type": "boolean", "description": "true → отозвать только unlimited/oversized approval. По умолчанию false (все ненулевые).", "default": false } + } + } } } + }, + "responses": { + "200": { "description": "Результат отзыва по каждой двери", "content": { "application/json": { "schema": { "type": "object", "properties": { "success": { "type": "boolean" }, "data": { "type": "object", "properties": { + "chain": { "type": "string" }, + "owner": { "type": "string" }, + "total": { "type": "integer", "description": "сколько открытых дверей нашлось" }, + "revoked": { "type": "array", "items": { "type": "object", "properties": { "token": { "type": "string" }, "spender": { "type": "string" }, "tokenAddress": { "type": "string" }, "spenderAddress": { "type": "string" }, "txid": { "type": "string" }, "previousAllowance": { "type": "string" } } } }, + "failed": { "type": "array", "items": { "type": "object", "properties": { "token": { "type": "string" }, "spender": { "type": "string" }, "error": { "type": "string" } } } }, + "skipped": { "type": "array", "items": { "type": "object", "properties": { "token": { "type": "string" }, "spender": { "type": "string" }, "reason": { "type": "string" } } } } + } } } } } } }, + "400": { "description": "chain ≠ ETH/BSC" }, + "403": { "description": "Нет кошелька для chain" }, + "502": { "description": "Scan failed / fatal" } + } + } + }, "/health": { "get": { "summary": "Liveness check", @@ -2816,6 +3385,61 @@ } } }, + "/wallets/{chain}/op-cost": { + "post": { + "summary": "Стоимость операции (свап/бридж): наша 0.7% в native+USD + проверка баланса токена/газа", + "description": "Read-only. По входному токену и сумме возвращает (всё в ОБЫЧНЫХ числах, не decimals): нашу комиссию 0.7% в самом токене, эквивалент в native-монете сети и в USD; и проверку баланса — хватает ли входного токена на операцию И нативной монеты на газ. Без mnemonic/broadcast/БД, JWT-bound (только свой кошелёк). Сетевую/gas-комиссию даёт swap/cost-estimate либо relay quote — здесь не дублируется. Если входной токен = native, токен-нога считается по нативному балансу, а газ-нога требует сумму + резерв.", + "tags": ["Wallet Ops"], + "parameters": [ + { "name": "chain", "in": "path", "required": true, "schema": { "type": "string", "enum": ["ETH", "BSC", "SOL", "TRX"] } } + ], + "requestBody": { + "required": true, + "content": { "application/json": { "schema": { + "type": "object", + "properties": { + "token": { "type": "string", "description": "Символ входного токена (USDT/USDC/…). Пусто или символ нативной монеты = нативная (ETH/BNB/SOL/TRX).", "example": "USDT" }, + "amount": { "type": "string", "description": "Сумма в smallest units (legacy). Используй ЛИБО amount, ЛИБО amountHuman." }, + "amountHuman": { "type": "string", "description": "Сумма в обычном виде (напр. \"10\").", "example": "10" } + } + } } } + }, + "responses": { + "200": { "description": "Стоимость операции + достаточность баланса (human-числа)", "content": { "application/json": { "schema": { + "type": "object", + "properties": { + "success": { "type": "boolean" }, + "data": { + "type": "object", + "properties": { + "chain": { "type": "string" }, + "input": { "type": "object", "properties": { "token": { "type": "string" }, "amount": { "type": "string" } } }, + "appCommission": { + "type": "object", + "properties": { + "ratePercent": { "type": "number", "example": 0.7 }, + "inToken": { "type": "object", "properties": { "symbol": { "type": "string" }, "amount": { "type": "string", "example": "0.07" } } }, + "inNative": { "type": "object", "properties": { "symbol": { "type": "string", "example": "ETH" }, "amount": { "type": "string", "nullable": true, "example": "0.0000218" } } }, + "usd": { "type": "number", "nullable": true, "example": 0.07 } + } + }, + "balance": { + "type": "object", + "properties": { + "token": { "type": "object", "properties": { "symbol": { "type": "string" }, "have": { "type": "string", "nullable": true }, "need": { "type": "string" }, "sufficient": { "type": "boolean", "nullable": true }, "shortfall": { "type": "string" } } }, + "gas": { "type": "object", "properties": { "symbol": { "type": "string" }, "have": { "type": "string", "nullable": true }, "needReserve": { "type": "string" }, "sufficient": { "type": "boolean", "nullable": true } } } + } + }, + "sufficient": { "type": "boolean" } + } + } + } + } } } }, + "400": { "description": "Невалидный token/amount или chain не ETH/BSC/SOL/TRX" }, + "404": { "description": "Нет кошелька для chain" } + } + } + }, "/relay/cost-estimate": { "post": { "summary": "Estimate USD cost of a bridge (Relay quote — trimmed, без steps[])",