Files
cryptowallet/apps/api/src/controllers/lp.controller.ts
ZOMBIIIIIII f8403eb9f6 initalll
2026-06-11 12:03:51 +03:00

196 lines
9.5 KiB
TypeScript

/**
* 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<string, unknown>): Promise<Ctx | null> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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' });
}
}