initalll
This commit is contained in:
748
apps/api/src/services/uniswap-lp.service.ts
Normal file
748
apps/api/src/services/uniswap-lp.service.ts
Normal 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 };
|
||||
}
|
||||
Reference in New Issue
Block a user