китаев.tech

Добавление ликвидности в Uniswap V3

Как создать LP-позицию в Uniswap V3 через NonfungiblePositionManager, выбрать tick range и добавить WETH/USDC ликвидность в ethers v6.

Ликвидность в V3 — это NFT-позиция

В Uniswap V3 ликвидность добавляют не во весь ценовой диапазон, а между двумя ticks: tickLower и tickUpper.

Каждая позиция хранится как NFT в NonfungiblePositionManager. NFT описывает пару токенов, fee tier, диапазон цены и количество активной ликвидности.


Создаём позицию WETH/USDC

Пример запускается на локальном fork Ethereum mainnet. Он использует реальные контракты Uniswap V3, но все транзакции остаются внутри локальной сети Hardhat.

Создайте проект:

bash
mkdir uniswap-v3-liquidity-example
cd uniswap-v3-liquidity-example

npm init -y
npm pkg set type="module"
npm install ethers tsx
npm install --save-dev hardhat typescript @types/node
mkdir src

Создайте файл hardhat.config.js:

javascript
export default {
  solidity: "0.8.20",
};

В первом терминале запустите mainnet fork:

bash
npx hardhat node --fork https://ethereum-rpc.publicnode.com

Создайте файл src/add-liquidity.ts:

typescript
import { ethers } from "ethers";

const LOCAL_FORK_RPC_URL = "http://127.0.0.1:8545";

const HARDHAT_ACCOUNT_PRIVATE_KEY =
  "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80";

const SWAP_ROUTER_ADDRESS = "0xE592427A0AEce92De3Edee1F18E0157C05861564";
const POSITION_MANAGER_ADDRESS = "0xC36442b4a4522E871399CD717aBDD847Ab11FE88";
const QUOTER_V2_ADDRESS = "0x61fFE014bA17989E743c5F6cB21bF9697530B21e";
const USDC_WETH_005_POOL = "0x88e6A0c2dDD26FEEb64F039a2c41296FcB3f5640";

const WETH_ADDRESS = "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2";
const USDC_ADDRESS = "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48";

const WETH_DECIMALS = 18;
const USDC_DECIMALS = 6;
const FEE_005_PERCENT = 500;
const SLIPPAGE_BPS = 50n; // 0.50%
const BPS_DENOMINATOR = 10_000n;

const WETH_ABI = [
  "function deposit() external payable",
  "function approve(address spender,uint256 amount) external returns (bool)",
  "function balanceOf(address account) external view returns (uint256)",
];

const ERC20_ABI = [
  "function approve(address spender,uint256 amount) external returns (bool)",
  "function balanceOf(address account) external view returns (uint256)",
];

const QUOTER_V2_ABI = [
  "function quoteExactInputSingle((address tokenIn,address tokenOut,uint256 amountIn,uint24 fee,uint160 sqrtPriceLimitX96) params) external returns (uint256 amountOut,uint160 sqrtPriceX96After,uint32 initializedTicksCrossed,uint256 gasEstimate)",
];

const SWAP_ROUTER_ABI = [
  "function exactInputSingle((address tokenIn,address tokenOut,uint24 fee,address recipient,uint256 deadline,uint256 amountIn,uint256 amountOutMinimum,uint160 sqrtPriceLimitX96) params) external payable returns (uint256 amountOut)",
];

const POOL_ABI = [
  "function slot0() external view returns (uint160 sqrtPriceX96,int24 tick,uint16 observationIndex,uint16 observationCardinality,uint16 observationCardinalityNext,uint8 feeProtocol,bool unlocked)",
  "function tickSpacing() external view returns (int24)",
];

const POSITION_MANAGER_ABI = [
  "function mint((address token0,address token1,uint24 fee,int24 tickLower,int24 tickUpper,uint256 amount0Desired,uint256 amount1Desired,uint256 amount0Min,uint256 amount1Min,address recipient,uint256 deadline) params) external payable returns (uint256 tokenId,uint128 liquidity,uint256 amount0,uint256 amount1)",
  "function ownerOf(uint256 tokenId) external view returns (address)",
];

function applySlippage(amount: bigint) {
  return (amount * (BPS_DENOMINATOR - SLIPPAGE_BPS)) / BPS_DENOMINATOR;
}

function nearestUsableTick(tick: number, tickSpacing: number) {
  return Math.round(tick / tickSpacing) * tickSpacing;
}

const provider = new ethers.JsonRpcProvider(LOCAL_FORK_RPC_URL);
const wallet = new ethers.Wallet(HARDHAT_ACCOUNT_PRIVATE_KEY, provider);

const weth = new ethers.Contract(WETH_ADDRESS, WETH_ABI, wallet);
const usdc = new ethers.Contract(USDC_ADDRESS, ERC20_ABI, wallet);
const quoter = new ethers.Contract(QUOTER_V2_ADDRESS, QUOTER_V2_ABI, provider);
const router = new ethers.Contract(SWAP_ROUTER_ADDRESS, SWAP_ROUTER_ABI, wallet);
const pool = new ethers.Contract(USDC_WETH_005_POOL, POOL_ABI, provider);
const positionManager = new ethers.Contract(
  POSITION_MANAGER_ADDRESS,
  POSITION_MANAGER_ABI,
  wallet,
);

console.log(`LP address: ${wallet.address}`);

const initialWeth = ethers.parseUnits("1", WETH_DECIMALS);
const depositTx = await weth.deposit({ value: initialWeth });
await depositTx.wait();

// Получаем USDC для второй стороны позиции.
const swapAmountIn = ethers.parseUnits("0.5", WETH_DECIMALS);
const [quotedUsdcOut] = await quoter.quoteExactInputSingle.staticCall({
  tokenIn: WETH_ADDRESS,
  tokenOut: USDC_ADDRESS,
  amountIn: swapAmountIn,
  fee: FEE_005_PERCENT,
  sqrtPriceLimitX96: 0,
});

await (await weth.approve(SWAP_ROUTER_ADDRESS, swapAmountIn)).wait();

await (
  await router.exactInputSingle({
    tokenIn: WETH_ADDRESS,
    tokenOut: USDC_ADDRESS,
    fee: FEE_005_PERCENT,
    recipient: wallet.address,
    deadline: Math.floor(Date.now() / 1000) + 60 * 10,
    amountIn: swapAmountIn,
    amountOutMinimum: applySlippage(quotedUsdcOut),
    sqrtPriceLimitX96: 0,
  })
).wait();

const [, currentTickRaw] = await pool.slot0();
const currentTick = Number(currentTickRaw);
const tickSpacing = Number(await pool.tickSpacing());

const tickLower = nearestUsableTick(currentTick - tickSpacing * 200, tickSpacing);
const tickUpper = nearestUsableTick(currentTick + tickSpacing * 200, tickSpacing);

const usdcBalance = await usdc.balanceOf(wallet.address);
const wethBalance = await weth.balanceOf(wallet.address);

const amount0Desired = (usdcBalance * 80n) / 100n;
const amount1Desired = (wethBalance * 50n) / 100n;

await (await usdc.approve(POSITION_MANAGER_ADDRESS, amount0Desired)).wait();
await (await weth.approve(POSITION_MANAGER_ADDRESS, amount1Desired)).wait();

const dryRunParams = {
  token0: USDC_ADDRESS,
  token1: WETH_ADDRESS,
  fee: FEE_005_PERCENT,
  tickLower,
  tickUpper,
  amount0Desired,
  amount1Desired,
  amount0Min: 0,
  amount1Min: 0,
  recipient: wallet.address,
  deadline: Math.floor(Date.now() / 1000) + 60 * 10,
};

const [expectedTokenId, expectedLiquidity, expectedAmount0, expectedAmount1] =
  await positionManager.mint.staticCall(dryRunParams);

const mintParams = {
  ...dryRunParams,
  amount0Min: applySlippage(expectedAmount0),
  amount1Min: applySlippage(expectedAmount1),
};

const mintTx = await positionManager.mint(mintParams);
await mintTx.wait();

const owner = await positionManager.ownerOf(expectedTokenId);

console.log(`Current tick: ${currentTick}`);
console.log(`Range: ${tickLower} ... ${tickUpper}`);
console.log(`Token ID: ${expectedTokenId.toString()}`);
console.log(`Owner: ${owner}`);
console.log(`Liquidity: ${expectedLiquidity.toString()}`);
console.log(`USDC used: ${ethers.formatUnits(expectedAmount0, USDC_DECIMALS)}`);
console.log(`WETH used: ${ethers.formatUnits(expectedAmount1, WETH_DECIMALS)}`);

Во втором терминале запустите скрипт:

bash
npx tsx src/add-liquidity.ts

Скрипт выведет примерно такой результат:

text
LP address: 0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266
Current tick: 195380
Range: 193380 ... 197380
Token ID: 915723
Owner: 0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266
Liquidity: 351928413460169
USDC used: 882.140912
WETH used: 0.25

Числа будут отличаться: fork берёт актуальное состояние mainnet на момент запуска.

Как это работает

POSITION_MANAGER_ADDRESS — адрес Uniswap V3 NonfungiblePositionManager в Ethereum mainnet.

USDC_WETH_005_POOL — пул USDC/WETH с fee tier 500, то есть 0.05%.

slot0() — возвращает текущее состояние пула. Для выбора диапазона нам нужен текущий tick.

tickSpacing() — шаг, которому должны соответствовать tickLower и tickUpper. Для fee tier 500 обычно используется шаг 10.

nearestUsableTick() — округляет границы диапазона до допустимых ticks.

amount0Desired и amount1Desired — максимальные суммы токенов, которые позиция может забрать. В примере мы используем часть фактического баланса после swap. В пуле USDC/WETH токен token0 — USDC, token1 — WETH.

mint.staticCall() — заранее симулирует mint и показывает, сколько USDC и WETH реально будет использовано при текущей цене.

amount0Min и amount1Min — защита от движения цены между симуляцией и транзакцией. Если mint потребует слишком мало одного из токенов, транзакция откатится.

mint() — создаёт NFT-позицию и добавляет ликвидность в выбранный диапазон.

Почему диапазон важен

В Uniswap V2 ликвидность распределялась по всей цене от 0 до бесконечности.

В Uniswap V3 LP выбирает диапазон. Если текущая цена находится внутри диапазона, позиция зарабатывает комиссии и содержит оба токена. Если цена выходит выше или ниже диапазона, позиция перестаёт быть активной и постепенно превращается в один из токенов.

Узкий диапазон даёт больше капитальной эффективности, но чаще выходит из рынка. Широкий диапазон работает стабильнее, но зарабатывает меньше комиссий на тот же капитал.

Частые ошибки

Перепутать token0 и token1 → в mint токены должны идти в порядке пула. Для USDC/WETH mainnet token0 — USDC, token1 — WETH.

Выбрать ticks без учёта tickSpacingtickLower и tickUpper должны делиться на tickSpacing, иначе mint откатится.

Ставить amount0Min и amount1Min в 0 → так позиция не защищена от движения цены. Используйте slippage-limit или SDK-расчёт.

Ожидать, что будут использованы все amountDesired → PositionManager берёт только нужное соотношение токенов. Остаток остаётся на кошельке.


Что дальше

Материалы китаev.tech публикуются в образовательных целях и не являются инвестиционной рекомендацией. Примеры кода и описания протоколов — для обучения; использование в продакшне на ваш собственный риск. Дисклеймер и политика конфиденциальности.