Files
cryptowallet/apps/api/src/services/uniswap-lp.service.ts
ZOMBIIIIIII 489d56ad36 initkjbnkhj
2026-06-23 21:30:54 +03:00

789 lines
32 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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';
// Оценочные gas-лимиты шагов LP-депозита (для networkFee в quote). Соответствуют реальным:
// mint=MINT_GAS_LIMIT, approve≈APPROVE_GAS_LIMIT, fee≈ERC20 transfer, wrap≈WETH.deposit.
const EST_GAS_MINT = 600_000n;
const EST_GAS_APPROVE = 80_000n;
const EST_GAS_FEE = 65_000n;
const EST_GAS_WRAP = 60_000n;
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<ethers.providers.StaticJsonRpcProvider> {
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<PoolData> {
const pool = new ethers.Contract(poolAddr, UNIV3_POOL_ABI, provider);
const [token0, token1, fee, tickSpacing, slot0, liquidity] = await Promise.all([
pool.token0() as Promise<string>,
pool.token1() as Promise<string>,
pool.fee() as Promise<number>,
pool.tickSpacing() as Promise<number>,
pool.slot0() as Promise<any>,
pool.liquidity() as Promise<ethers.BigNumber>,
]);
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<number>,
t1.decimals() as Promise<number>,
t0.symbol().catch(() => '?') as Promise<string>,
t1.symbol().catch(() => '?') as Promise<string>,
]);
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<PoolInfo[]> {
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-эквивалент)
// ── Оценка сетевой (gas) комиссии всего LP-депозита (wrap?+fee×ноги+approve×ноги+mint) ──
networkFee: {
estGasUnits: string;
gasPriceGwei: string | null;
amountEthHuman: string | null; // оценка газа в ETH
amountUsd: number | null; // та же оценка в USD
};
// ── Проверка баланса (только если передан 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<AddQuote> {
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 */ }
// ── Оценка сетевой (gas) комиссии: суммируем gas-лимиты шагов, которые реально пойдут ──
const t0IsWethN = isWeth(pool.token0);
const t1IsWethN = isWeth(pool.token1);
let estGas = EST_GAS_MINT; // mint всегда
if (in0 > 0n) estGas += EST_GAS_APPROVE + EST_GAS_FEE; // нога token0: approve + fee
if (in1 > 0n) estGas += EST_GAS_APPROVE + EST_GAS_FEE; // нога token1
if (p.useNativeEth && (t0IsWethN || t1IsWethN)) estGas += EST_GAS_WRAP; // авто-wrap ETH→WETH
let gasPriceWei = 0n;
try {
const fd = await provider.getFeeData();
const gp = fd.maxFeePerGas ?? fd.gasPrice ?? null;
if (gp) gasPriceWei = BigInt(gp.toString());
} catch { /* gas price optional */ }
const networkFeeWei = estGas * gasPriceWei;
const networkFeeEthHuman = gasPriceWei > 0n ? formatSmallestUnits(networkFeeWei.toString(), 18) : null;
const networkFeeUsd = (networkFeeEthHuman != null && ethPrice != null)
? roundUsd(Number(networkFeeEthHuman) * ethPrice) : null;
const gasPriceGwei = gasPriceWei > 0n ? formatSmallestUnits(gasPriceWei.toString(), 9) : null;
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<LegBalance> => {
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,
networkFee: {
estGasUnits: estGas.toString(),
gasPriceGwei,
amountEthHuman: networkFeeEthHuman,
amountUsd: networkFeeUsd,
},
...(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<AddResult> {
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)}`);
}
}
// WETH-нога при useNativeEth: заворачиваем ТОЛЬКО недостающее (учитываем уже имеющийся WETH от прошлых
// попыток — не плодим WETH и не требуем лишний ETH). wethDeficit переиспользуется в шаге wrap ниже.
let wethDeficit = 0n;
if (p.useNativeEth) {
const wrapAmt = t0IsWeth ? a0 : t1IsWeth ? a1 : 0n;
const wethLegAddr = t0IsWeth ? pool.token0 : t1IsWeth ? pool.token1 : '';
if (wrapAmt > 0n && wethLegAddr) {
let wethBal = 0n;
try { wethBal = await readErc20Balance('ETH', wethLegAddr, p.expectedFromAddress); } catch { /* assume 0 */ }
wethDeficit = wethBal >= wrapAmt ? 0n : wrapAmt - wethBal;
const ethBal = await readEvmNativeBalance('ETH', p.expectedFromAddress);
const need = wethDeficit + ethers.utils.parseEther('0.001').toBigInt(); // недостающий WETH + буфер на газ
if (ethBal < need) {
throw badRequest(`Недостаточно ETH: нужно ~${formatSmallestUnits(need.toString(), 18)} (wrap недостающего WETH + газ), есть ${formatSmallestUnits(ethBal.toString(), 18)}`);
}
}
}
// useNativeEth: заворачиваем ТОЛЬКО недостающий WETH (wethDeficit). Если WETH уже хватает — пропускаем
// (используем имеющийся). Дальше WETH = обычный ERC20: fee/approve/mint без изменений.
let wrapTxid: string | undefined;
if (p.useNativeEth && wethDeficit > 0n) {
const w = await signAndBroadcastWrapEth({
mnemonic: p.mnemonic, expectedFromAddress: p.expectedFromAddress, amountWei: wethDeficit.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<RemoveResult> {
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<string, PoolData>();
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 };
}