Реализация интерфейса ликвидности (LP) на сайте
LP-интерфейс позволяет пользователям добавлять и выводить ликвидность из пула AMM. Это сложнее стейкинга: нужно понимать соотношение токенов в пуле, рассчитывать пропорции при добавлении, показывать долю в пуле и impermanent loss. Плюс — approve для двух токенов, а не одного.
Модели AMM
Два основных варианта, которые встречаются в проектах:
Uniswap v2 / SushiSwap (constant product x·y=k): простая пропорция, LP-токены ERC-20, фиксированная 0.3% комиссия.
Uniswap v3 (concentrated liquidity): пользователь выбирает ценовой диапазон, позиция — NFT, более сложный расчёт.
Разбираем Uniswap v2 как основу — большинство custom AMM строится на этой модели.
Чтение состояния пула
// lib/pool.ts
import { createPublicClient, http, parseAbi } from 'viem';
const PAIR_ABI = parseAbi([
'function getReserves() view returns (uint112 reserve0, uint112 reserve1, uint32 blockTimestampLast)',
'function totalSupply() view returns (uint256)',
'function balanceOf(address) view returns (uint256)',
'function token0() view returns (address)',
'function token1() view returns (address)',
'function kLast() view returns (uint256)',
]);
const ROUTER_ABI = parseAbi([
'function addLiquidity(address,address,uint256,uint256,uint256,uint256,address,uint256) returns (uint256,uint256,uint256)',
'function removeLiquidity(address,address,uint256,uint256,uint256,address,uint256) returns (uint256,uint256)',
'function quote(uint256 amountA, uint256 reserveA, uint256 reserveB) pure returns (uint256 amountB)',
]);
export interface PoolState {
reserve0: bigint;
reserve1: bigint;
totalSupply: bigint;
userLPBalance: bigint;
token0: `0x${string}`;
token1: `0x${string}`;
// Вычисляемые
userShare: number; // доля пользователя в пуле, 0–1
userToken0: bigint; // сколько token0 можно вывести
userToken1: bigint;
}
export async function getPoolState(
pairAddress: `0x${string}`,
userAddress?: `0x${string}`,
client = createPublicClient({ chain: mainnet, transport: http() }),
): Promise<PoolState> {
const results = await client.multicall({
contracts: [
{ address: pairAddress, abi: PAIR_ABI, functionName: 'getReserves' },
{ address: pairAddress, abi: PAIR_ABI, functionName: 'totalSupply' },
{ address: pairAddress, abi: PAIR_ABI, functionName: 'token0' },
{ address: pairAddress, abi: PAIR_ABI, functionName: 'token1' },
...(userAddress ? [{ address: pairAddress, abi: PAIR_ABI, functionName: 'balanceOf', args: [userAddress] }] : []),
],
});
const [r0, r1] = results[0].result as [bigint, bigint, number];
const totalSupply = results[1].result as bigint;
const token0 = results[2].result as `0x${string}`;
const token1 = results[3].result as `0x${string}`;
const userLPBalance = userAddress ? (results[4].result as bigint) : 0n;
const userShare = totalSupply > 0n ? Number(userLPBalance * 10000n / totalSupply) / 10000 : 0;
const userToken0 = totalSupply > 0n ? r0 * userLPBalance / totalSupply : 0n;
const userToken1 = totalSupply > 0n ? r1 * userLPBalance / totalSupply : 0n;
return { reserve0: r0, reserve1: r1, totalSupply, userLPBalance, token0, token1, userShare, userToken0, userToken1 };
}
Расчёт пропорций при добавлении ликвидности
При добавлении ликвидности в непустой пул второй токен рассчитывается автоматически по текущей цене пула:
// Пользователь вводит количество token0 → рассчитываем token1
export function quoteToken1(
amount0: bigint,
reserve0: bigint,
reserve1: bigint,
): bigint {
if (reserve0 === 0n) return 0n; // пустой пул — пользователь задаёт соотношение сам
return (amount0 * reserve1) / reserve0;
}
// И обратно
export function quoteToken0(amount1: bigint, reserve0: bigint, reserve1: bigint): bigint {
if (reserve1 === 0n) return 0n;
return (amount1 * reserve0) / reserve1;
}
// Расчёт LP-токенов, которые получит пользователь
export function calcLPOut(
amount0: bigint,
amount1: bigint,
reserve0: bigint,
reserve1: bigint,
totalSupply: bigint,
): bigint {
if (totalSupply === 0n) {
// Первый провайдер ликвидности — формула sqrt(amount0 * amount1) - MINIMUM_LIQUIDITY
const MINIMUM_LIQUIDITY = 1000n;
return sqrt(amount0 * amount1) - MINIMUM_LIQUIDITY;
}
const lp0 = (amount0 * totalSupply) / reserve0;
const lp1 = (amount1 * totalSupply) / reserve1;
return lp0 < lp1 ? lp0 : lp1; // min
}
function sqrt(n: bigint): bigint {
if (n < 0n) throw new Error('sqrt of negative');
if (n < 2n) return n;
let x = n;
let y = (x + 1n) / 2n;
while (y < x) { x = y; y = (x + n / x) / 2n; }
return x;
}
Форма добавления ликвидности
// components/AddLiquidityForm.tsx
export function AddLiquidityForm({ pool }: { pool: PoolState }) {
const [amount0, setAmount0] = useState('');
const [amount1, setAmount1] = useState('');
const decimals0 = 18; // получаем из контракта токена
const decimals1 = 6; // USDC
const handleAmount0Change = (val: string) => {
setAmount0(val);
if (!val || pool.reserve0 === 0n) return;
const wei0 = parseUnits(val, decimals0);
const wei1 = quoteToken1(wei0, pool.reserve0, pool.reserve1);
setAmount1(formatUnits(wei1, decimals1));
};
const handleAmount1Change = (val: string) => {
setAmount1(val);
if (!val || pool.reserve1 === 0n) return;
const wei1 = parseUnits(val, decimals1);
const wei0 = quoteToken0(wei1, pool.reserve0, pool.reserve1);
setAmount0(formatUnits(wei0, decimals0));
};
// Slippage 0.5% по умолчанию
const slippage = 0.005;
const amount0Wei = amount0 ? parseUnits(amount0, decimals0) : 0n;
const amount1Wei = amount1 ? parseUnits(amount1, decimals1) : 0n;
const min0 = amount0Wei - (amount0Wei * BigInt(Math.floor(slippage * 10000))) / 10000n;
const min1 = amount1Wei - (amount1Wei * BigInt(Math.floor(slippage * 10000))) / 10000n;
const lpOut = calcLPOut(amount0Wei, amount1Wei, pool.reserve0, pool.reserve1, pool.totalSupply);
return (
<div className="space-y-4">
<TokenInput
token="TOKEN"
value={amount0}
onChange={handleAmount0Change}
balance={walletBalance0}
/>
<div className="flex justify-center">
<PlusIcon className="h-5 w-5 text-neutral-500" />
</div>
<TokenInput
token="USDC"
value={amount1}
onChange={handleAmount1Change}
balance={walletBalance1}
/>
<div className="rounded-lg bg-neutral-800/50 p-4 space-y-2 text-sm">
<Row label="Доля в пуле" value={`${(parseFloat(formatUnits(lpOut, 18)) / parseFloat(formatUnits(pool.totalSupply + lpOut, 18)) * 100).toFixed(4)}%`} />
<Row label="Получите LP" value={`${formatUnits(lpOut, 18)}`} />
<Row label="Мин. TOKEN (slippage 0.5%)" value={formatUnits(min0, decimals0)} />
<Row label="Мин. USDC" value={formatUnits(min1, decimals1)} />
</div>
<AddLiquidityButton amount0={amount0Wei} amount1={amount1Wei} min0={min0} min1={min1} />
</div>
);
}
Вывод ликвидности
// hooks/useRemoveLiquidity.ts
export function useRemoveLiquidity() {
const { writeContractAsync } = useWriteContract();
const removeLiquidity = async (
token0: `0x${string}`,
token1: `0x${string}`,
lpAmount: bigint,
minAmount0: bigint,
minAmount1: bigint,
) => {
// Сначала approve LP-токена для router
const approveTx = await writeContractAsync({
address: PAIR_ADDRESS,
abi: erc20Abi,
functionName: 'approve',
args: [ROUTER_ADDRESS, lpAmount],
});
await waitForTransactionReceipt(config, { hash: approveTx });
// Вывод ликвидности
return writeContractAsync({
address: ROUTER_ADDRESS,
abi: ROUTER_ABI,
functionName: 'removeLiquidity',
args: [token0, token1, lpAmount, minAmount0, minAmount1, account.address, BigInt(Math.floor(Date.now() / 1000) + 1200)],
});
};
return { removeLiquidity };
}
Impermanent Loss калькулятор
export function calcImpermanentLoss(priceRatioChange: number): number {
// priceRatioChange = текущий_price_ratio / начальный_price_ratio
const k = Math.sqrt(priceRatioChange);
const lpValue = (2 * k) / (1 + priceRatioChange);
return (1 - lpValue) * 100; // % потери
}
// Пример: цена token0 выросла в 2 раза относительно token1
calcImpermanentLoss(2); // ~5.7% impermanent loss
calcImpermanentLoss(4); // ~20% impermanent loss
Сроки: LP-интерфейс для стандартного Uniswap v2 клона с добавлением/выводом ликвидности, расчётом пропорций и IL-калькулятором — 7–10 дней. Uniswap v3 с concentrated liquidity и выбором ценового диапазона — 2–3 недели.







