196 lines
9.5 KiB
TypeScript
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' });
|
|
}
|
|
}
|