789 lines
32 KiB
TypeScript
789 lines
32 KiB
TypeScript
/**
|
||
* 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 };
|
||
}
|