китаев.tech

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, но не отправляет транзакции в реальную сеть.

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

bash
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:

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

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

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

Создайте файл src/swap-weth-usdc.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 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:

bash
npx tsx src/swap-weth-usdc.ts

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

text
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.


Что дальше

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