/** * 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' }); } }