/** * 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 { 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-эквивалент) // ── Оценка сетевой (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 { 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 => { 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 { 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 { 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 }; }