Deep EVM #8: Полный своп токенов на чистом Yul
Engineering Team
Цель проекта
В этой финальной статье серии мы объединяем все изученные концепции — опкоды, управление памятью, газоэффективные циклы, безопасность — и строим реальный контракт: своп токенов через Uniswap V2, написанный целиком на Yul.
Этот контракт будет:
- Принимать вызов
swap(address tokenIn, address tokenOut, uint256 amountIn, uint256 amountOutMin) - Переводить
tokenInот пользователя на контракт - Одобрять трансфер для пула Uniswap V2
- Рассчитывать выходное количество
- Выполнять своп через пул
- Переводить полученные токены пользователю
Структура контракта на Yul
Standalone Yul-контракт состоит из двух секций: конструктор (init code) и runtime code:
object "TokenSwap" {
// Конструктор: выполняется при деплое
code {
// Сохраняем owner в слот 0
sstore(0, caller())
// Копируем runtime code в память и возвращаем
let size := datasize("runtime")
let offset := dataoffset("runtime")
datacopy(0, offset, size)
return(0, size)
}
object "runtime" {
// Runtime code: выполняется при каждом вызове
code {
// Диспетчеризация функций
switch selector()
case 0xd004f0f7 /* swap(address,address,uint256,uint256) */ {
swap()
}
case 0x8da5cb5b /* owner() */ {
returnUint(sload(0))
}
default {
revert(0, 0)
}
// === Вспомогательные функции ===
function selector() -> s {
s := shr(224, calldataload(0))
}
function returnUint(v) {
mstore(0x00, v)
return(0x00, 0x20)
}
function revertWithReason(msg, len) {
mstore(0x00, 0x08c379a000000000000000000000000000000000000000000000000000000000)
mstore(0x04, 0x20)
mstore(0x24, len)
mstore(0x44, msg)
revert(0x00, 0x64)
}
// ... (функции определены ниже)
}
}
}
Шаг 1: Диспетчеризация функций
Первое, что делает контракт при получении вызова — определяет, какую функцию вызвать:
function selector() -> s {
// Загружаем первые 32 байта calldata
// Сдвигаем вправо на 224 бита, оставляя 4 байта селектора
s := shr(224, calldataload(0))
}
Селектор функции — это первые 4 байта от keccak256 сигнатуры:
// keccak256("swap(address,address,uint256,uint256)") = 0xd004f0f7...
// keccak256("owner()") = 0x8da5cb5b...
На уровне байткода switch/case компилируется в серию PUSH4 + EQ + PUSH2 + JUMPI — точно так же, как Solidity делает диспетчеризацию.
Шаг 2: Безопасный трансфер токенов
Одна из самых критичных функций — безопасный вызов transfer и transferFrom на ERC-20 токенах:
function safeTransferFrom(token, from, to, amount) {
// Селектор transferFrom(address,address,uint256) = 0x23b872dd
mstore(0x00, 0x23b872dd00000000000000000000000000000000000000000000000000000000)
mstore(0x04, from)
mstore(0x24, to)
mstore(0x44, amount)
let success := call(gas(), token, 0, 0x00, 0x64, 0x00, 0x20)
// Проверка: вызов успешен И (нет returndata ИЛИ returndata == true)
if iszero(and(
success,
or(
iszero(returndatasize()),
and(gt(returndatasize(), 31), eq(mload(0x00), 1))
)
)) {
// "Transfer failed"
revertWithReason("Transfer failed", 15)
}
}
function safeTransfer(token, to, amount) {
// Селектор transfer(address,uint256) = 0xa9059cbb
mstore(0x00, 0xa9059cbb00000000000000000000000000000000000000000000000000000000)
mstore(0x04, to)
mstore(0x24, amount)
let success := call(gas(), token, 0, 0x00, 0x44, 0x00, 0x20)
if iszero(and(
success,
or(
iszero(returndatasize()),
and(gt(returndatasize(), 31), eq(mload(0x00), 1))
)
)) {
revertWithReason("Transfer failed", 15)
}
}
function safeApprove(token, spender, amount) {
// Селектор approve(address,uint256) = 0x095ea7b3
mstore(0x00, 0x095ea7b300000000000000000000000000000000000000000000000000000000)
mstore(0x04, spender)
mstore(0x24, amount)
let success := call(gas(), token, 0, 0x00, 0x44, 0x00, 0x20)
if iszero(and(
success,
or(
iszero(returndatasize()),
and(gt(returndatasize(), 31), eq(mload(0x00), 1))
)
)) {
revertWithReason("Approve failed", 14)
}
}
Паттерн проверки or(iszero(returndatasize()), and(gt(returndatasize(), 31), eq(mload(0x00), 1))) обрабатывает оба случая:
- Токены, не возвращающие данные (USDT) —
returndatasize() == 0 - Токены, возвращающие bool (стандартные ERC-20) —
mload(0x00) == 1
Шаг 3: Вычисление адреса пула
Uniswap V2 использует CREATE2 для детерминированных адресов пулов:
function getPair(factory, tokenA, tokenB) -> pair {
// Сортируем токены: token0 < token1
let token0 := tokenA
let token1 := tokenB
if gt(tokenA, tokenB) {
token0 := tokenB
token1 := tokenA
}
// Вычисляем salt = keccak256(abi.encodePacked(token0, token1))
mstore(0x00, token0)
mstore(0x20, token1)
let salt := keccak256(0x00, 0x40)
// Вычисляем адрес CREATE2:
// address = keccak256(0xff ++ factory ++ salt ++ init_code_hash)[12:]
mstore(0x00, 0xff00000000000000000000000000000000000000000000000000000000000000)
mstore8(1, shr(88, factory)) // вставляем factory адрес побайтно
// ... (упрощение — на практике используем полный расчёт)
// Для демонстрации — вызываем factory.getPair()
mstore(0x00, 0xe6a4390500000000000000000000000000000000000000000000000000000000)
mstore(0x04, tokenA)
mstore(0x24, tokenB)
let success := staticcall(gas(), factory, 0x00, 0x44, 0x00, 0x20)
if iszero(success) { revert(0, 0) }
pair := mload(0x00)
if iszero(pair) {
revertWithReason("Pair not found", 14)
}
}
Шаг 4: Расчёт выходного количества
Формула Uniswap V2 для расчёта выходного количества:
amountOut = (amountIn * 997 * reserveOut) / (reserveIn * 1000 + amountIn * 997)
На Yul:
function getAmountOut(amountIn, reserveIn, reserveOut) -> amountOut {
// amountIn должен быть > 0
if iszero(amountIn) {
revertWithReason("Zero input", 10)
}
// Резервы должны быть > 0
if or(iszero(reserveIn), iszero(reserveOut)) {
revertWithReason("No liquidity", 12)
}
let amountInWithFee := mul(amountIn, 997)
let numerator := mul(amountInWithFee, reserveOut)
let denominator := add(mul(reserveIn, 1000), amountInWithFee)
amountOut := div(numerator, denominator)
}
function getReserves(pair, tokenA, tokenB) -> reserveA, reserveB {
// Вызов getReserves() на пуле
mstore(0x00, 0x0902f1ac00000000000000000000000000000000000000000000000000000000)
let success := staticcall(gas(), pair, 0x00, 0x04, 0x00, 0x60)
if iszero(success) { revert(0, 0) }
let reserve0 := mload(0x00)
let reserve1 := mload(0x20)
// Определяем порядок токенов
switch lt(tokenA, tokenB)
case 1 {
reserveA := reserve0
reserveB := reserve1
}
default {
reserveA := reserve1
reserveB := reserve0
}
}
Шаг 5: Выполнение свопа
Главная функция, объединяющая все шаги:
function swap() {
// Декодируем аргументы из calldata
let tokenIn := and(calldataload(4), 0xffffffffffffffffffffffffffffffffffffffff)
let tokenOut := and(calldataload(36), 0xffffffffffffffffffffffffffffffffffffffff)
let amountIn := calldataload(68)
let amountOutMin := calldataload(100)
// Валидация
if iszero(amountIn) {
revertWithReason("Zero amount", 11)
}
// Адрес Uniswap V2 Factory (Ethereum mainnet)
let factory := 0x5C69bEe701ef814a2B6a3EDD4B1652CB9cc5aA6f
// 1. Получаем адрес пула
let pair := getPair(factory, tokenIn, tokenOut)
// 2. Переводим tokenIn от пользователя на пул
safeTransferFrom(tokenIn, caller(), pair, amountIn)
// 3. Получаем резервы
let reserveIn, reserveOut := getReserves(pair, tokenIn, tokenOut)
// 4. Рассчитываем выходное количество
let amountOut := getAmountOut(amountIn, reserveIn, reserveOut)
// 5. Проверяем slippage
if lt(amountOut, amountOutMin) {
revertWithReason("Slippage", 8)
}
// 6. Определяем порядок amount0Out/amount1Out
let amount0Out := 0
let amount1Out := 0
switch lt(tokenIn, tokenOut)
case 1 {
// tokenIn = token0, tokenOut = token1
amount1Out := amountOut
}
default {
// tokenIn = token1, tokenOut = token0
amount0Out := amountOut
}
// 7. Вызываем pair.swap(amount0Out, amount1Out, to, data)
mstore(0x00, 0x022c0d9f00000000000000000000000000000000000000000000000000000000)
mstore(0x04, amount0Out)
mstore(0x24, amount1Out)
mstore(0x44, caller()) // получатель — вызывающий
mstore(0x64, 0x80) // смещение до data
mstore(0x84, 0) // длина data = 0 (нет flash swap)
let success := call(gas(), pair, 0, 0x00, 0xa4, 0x00, 0x00)
if iszero(success) {
// Копируем revert reason
let rSize := returndatasize()
returndatacopy(0x00, 0x00, rSize)
revert(0x00, rSize)
}
// 8. Возвращаем amountOut
returnUint(amountOut)
}
Анализ газа
Сравним наш Yul-контракт с эквивалентом на Solidity:
| Операция | Solidity | Yul | Экономия |
|---|---|---|---|
| Диспетчеризация функций | ~200 | ~80 | 60% |
| Декодирование calldata | ~500 | ~50 | 90% |
| safeTransferFrom | ~1500 | ~800 | 47% |
| getReserves | ~1000 | ~500 | 50% |
| Расчёт amountOut | ~300 | ~100 | 67% |
| Вызов pair.swap | ~1200 | ~600 | 50% |
| Итого (без внешних вызовов) | ~4700 | ~2130 | 55% |
Общая стоимость свопа останется высокой (~120000-150000 газа) из-за SLOAD/SSTORE внутри пула. Но для MEV-ботов, выполняющих тысячи свопов в день, экономия ~2500 газа за своп — это существенная прибыль.
Безопасность контракта
Наш контракт имеет несколько важных ограничений:
- Нет проверки переполнения — расчёт
amountInWithFee * reserveOutможет переполниться для очень больших значений. В продакшене используйтеmulmodили проверки. - Фронтраннинг —
amountOutMinзащищает от слишком большого проскальзывания, но не от сэндвич-атак. - Нет deadline — в продакшене добавьте проверку
timestamp < deadline. - Одноразовый approve — для максимальной безопасности используйте
approveна точную сумму.
Деплой и тестирование
Для компиляции и деплоя standalone Yul-контракта:
# Компиляция с solc
solc --strict-assembly --optimize --optimize-runs 200 swap.yul
# Тестирование с Foundry
forge test -vvvv --match-contract SwapTest
Итоги серии
За восемь статей серии Deep EVM мы прошли путь от базовых опкодов до полноценного контракта на Yul:
- Опкоды, стек, газ — фундамент EVM
- Модель памяти — стек, память, хранилище, calldata
- Экономика газа — стоимость операций и оптимизация
- Безопасность — msg.sender, реентрабельность, CEI
- Введение в Yul — синтаксис и встроенные функции
- Управление памятью — mstore, mload, свободный указатель
- Циклы и условия — газоэффективные паттерны
- Своп на Yul — применение всех концепций
Эти знания — фундамент для серьёзной разработки смарт-контрактов, аудита безопасности и создания MEV-ботов. Понимание EVM на уровне опкодов отличает хорошего разработчика от великого.