Swap через Uniswap V3 Router
Как выполнить swap WETH в USDC через Uniswap V3 SwapRouter, рассчитать минимальный результат и отправить транзакцию в ethers v6.
Router выполняет swap
Uniswap V3 SwapRouter — это контракт, через который пользователь отправляет токены в пул и получает другой токен обратно.
Для ERC-20 swap нужен стандартный flow: сначала approve для Router, затем вызов exactInputSingle с адресами токенов, fee tier, входной суммой и минимальным выходом.
Меняем WETH на USDC
Пример запускается на локальном fork Ethereum mainnet. Он использует настоящие mainnet-контракты Uniswap, WETH и USDC, но не отправляет транзакции в реальную сеть.
Создайте проект:
mkdir uniswap-v3-swap-example
cd uniswap-v3-swap-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/swap-weth-usdc.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 QUOTER_V2_ADDRESS = "0x61fFE014bA17989E743c5F6cB21bF9697530B21e";
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 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 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, provider);
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 amountIn = ethers.parseUnits("1", WETH_DECIMALS);
console.log(`Trader: ${wallet.address}`);
const depositTx = await weth.deposit({ value: amountIn });
await depositTx.wait();
const wethBalance = await weth.balanceOf(wallet.address);
console.log(`WETH balance: ${ethers.formatUnits(wethBalance, WETH_DECIMALS)}`);
const [quotedAmountOut] = await quoter.quoteExactInputSingle.staticCall({
tokenIn: WETH_ADDRESS,
tokenOut: USDC_ADDRESS,
amountIn,
fee: FEE_005_PERCENT,
sqrtPriceLimitX96: 0,
});
const amountOutMinimum =
(quotedAmountOut * (BPS_DENOMINATOR - SLIPPAGE_BPS)) / BPS_DENOMINATOR;
console.log(`Quote: ${ethers.formatUnits(quotedAmountOut, USDC_DECIMALS)} USDC`);
console.log(
`Minimum: ${ethers.formatUnits(amountOutMinimum, USDC_DECIMALS)} USDC`,
);
const approveTx = await weth.approve(SWAP_ROUTER_ADDRESS, amountIn);
await approveTx.wait();
const usdcBefore = await usdc.balanceOf(wallet.address);
const deadline = Math.floor(Date.now() / 1000) + 60 * 10;
const swapTx = await router.exactInputSingle({
tokenIn: WETH_ADDRESS,
tokenOut: USDC_ADDRESS,
fee: FEE_005_PERCENT,
recipient: wallet.address,
deadline,
amountIn,
amountOutMinimum,
sqrtPriceLimitX96: 0,
});
const receipt = await swapTx.wait();
const usdcAfter = await usdc.balanceOf(wallet.address);
const received = usdcAfter - usdcBefore;
console.log(`Swap tx: ${receipt?.hash}`);
console.log(`Received: ${ethers.formatUnits(received, USDC_DECIMALS)} USDC`);Во втором терминале запустите swap:
npx tsx src/swap-weth-usdc.tsСкрипт выведет примерно такой результат:
Trader: 0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266
WETH balance: 1.0
Quote: 3542.184203 USDC
Minimum: 3524.473281 USDC
Swap tx: 0x...
Received: 3542.184203 USDCЧисла меняются вместе с состоянием mainnet fork.
Как это работает
SWAP_ROUTER_ADDRESS — адрес Uniswap V3 SwapRouter в Ethereum mainnet.
weth.deposit() — оборачивает ETH локального Hardhat-аккаунта в WETH. Router работает с ERC-20 токеном, поэтому для примера удобнее сначала получить WETH.
quoteExactInputSingle.staticCall() — считает ожидаемый выход через QuoterV2. Это не swap, а симуляция.
amountOutMinimum — минимальное количество USDC, которое мы готовы получить. Если цена ухудшится сильнее чем на 0.50%, Router откатит транзакцию.
weth.approve(SWAP_ROUTER_ADDRESS, amountIn) — разрешает Router списать ровно 1 WETH с адреса пользователя.
exactInputSingle() — выполняет swap через один Uniswap V3 пул. Для multi-hop маршрута используется exactInput с encoded path.
deadline — время, после которого транзакция станет недействительной. Это защита от исполнения старого swap в другой рыночной ситуации.
sqrtPriceLimitX96: 0 — без жёсткого лимита цены на уровне тика. В обычном UI-flow slippage контролируют через amountOutMinimum.
Почему сначала approve
Router не владеет токенами пользователя и не может сам забрать WETH из кошелька.
approve записывает allowance в контракт WETH: пользователь разрешает конкретному spender-адресу потратить конкретную сумму. После этого Router вызывает transferFrom и забирает amountIn в момент swap.
Для ETH flow есть отдельные маршруты через payable-вызовы и обёртку в WETH, но базовый DeFi-паттерн для ERC-20 всегда начинается с allowance.
Частые ошибки
Ставить amountOutMinimum: 0 в продакшене → транзакция примет любой результат. Сначала получите quote и примените slippage-limit.
Делать unlimited approve без причины → для простого swap разрешайте ровно нужную сумму. Unlimited allowance удобен, но увеличивает риск при ошибке или компрометации spender-контракта.
Путать Quoter и Router → Quoter только считает результат, Router выполняет swap и меняет балансы.
Выбирать fee tier наугад → для WETH/USDC на mainnet часто используют 500, но у других пар ликвидность может быть в 3000 или 10000.
Что дальше
- Цена токена из Uniswap V3 через Quoter — получить quote до отправки swap
- ERC-20: approve и allowance — понять, как Router получает право списать токены
- Frontrunning и MEV — зачем нужны slippage-limit и deadline