This commit is contained in:
ZOMBIIIIIII
2026-06-11 12:03:51 +03:00
parent 399322973e
commit f8403eb9f6
23 changed files with 3998 additions and 6 deletions

View File

@@ -0,0 +1,748 @@
/**
* 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';
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-эквивалент)
// ── Проверка баланса (только если передан 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 */ }
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,
...(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)}`);
}
}
if (p.useNativeEth) {
const wrapAmt = t0IsWeth ? a0 : t1IsWeth ? a1 : 0n;
if (wrapAmt > 0n) {
const ethBal = await readEvmNativeBalance('ETH', p.expectedFromAddress);
const need = wrapAmt + ethers.utils.parseEther('0.001').toBigInt(); // wrap + буфер на газ
if (ethBal < need) {
throw badRequest(`Недостаточно ETH: нужно ~${formatSmallestUnits(need.toString(), 18)} (wrap + газ), есть ${formatSmallestUnits(ethBal.toString(), 18)}`);
}
}
}
// useNativeEth: если у пула есть WETH-нога — заворачиваем нужный ETH в WETH (WETH.deposit, ждёт
// 1 conf). Дальше WETH = обычный ERC20: fee/approve/mint без изменений. Юзеру нужен ETH + второй токен.
let wrapTxid: string | undefined;
if (p.useNativeEth) {
let wrapAmount = 0n;
if (isWeth(pool.token0)) wrapAmount = a0;
else if (isWeth(pool.token1)) wrapAmount = a1;
if (wrapAmount > 0n) {
const w = await signAndBroadcastWrapEth({
mnemonic: p.mnemonic, expectedFromAddress: p.expectedFromAddress, amountWei: wrapAmount.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 };
}