Добавление ликвидности в 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.
Создайте проект:
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:
export default {
solidity: "0.8.20",
};В первом терминале запустите mainnet fork:
npx hardhat node --fork https://ethereum-rpc.publicnode.comСоздайте файл src/add-liquidity.ts:
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)}`);Во втором терминале запустите скрипт:
npx tsx src/add-liquidity.tsСкрипт выведет примерно такой результат:
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 без учёта tickSpacing → tickLower и tickUpper должны делиться на tickSpacing, иначе mint откатится.
Ставить amount0Min и amount1Min в 0 → так позиция не защищена от движения цены. Используйте slippage-limit или SDK-расчёт.
Ожидать, что будут использованы все amountDesired → PositionManager берёт только нужное соотношение токенов. Остаток остаётся на кошельке.
Что дальше
- Swap через Uniswap V3 Router — получить второй токен перед добавлением ликвидности
- Цена токена из Uniswap V3 через Quoter — проверить spot-цену и price impact
- ERC-20: approve и allowance — понять, почему PositionManager нужен allowance