Перейти к основному содержимому
БлокчейнMar 28, 2026

Deep EVM #8: Полный своп токенов на чистом Yul

OS
Open Soft Team

Engineering Team

Цель проекта

В этой финальной статье серии мы объединяем все изученные концепции — опкоды, управление памятью, газоэффективные циклы, безопасность — и строим реальный контракт: своп токенов через Uniswap V2, написанный целиком на Yul.

Этот контракт будет:

  1. Принимать вызов swap(address tokenIn, address tokenOut, uint256 amountIn, uint256 amountOutMin)
  2. Переводить tokenIn от пользователя на контракт
  3. Одобрять трансфер для пула Uniswap V2
  4. Рассчитывать выходное количество
  5. Выполнять своп через пул
  6. Переводить полученные токены пользователю

Структура контракта на 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:

ОперацияSolidityYulЭкономия
Диспетчеризация функций~200~8060%
Декодирование calldata~500~5090%
safeTransferFrom~1500~80047%
getReserves~1000~50050%
Расчёт amountOut~300~10067%
Вызов pair.swap~1200~60050%
Итого (без внешних вызовов)~4700~213055%

Общая стоимость свопа останется высокой (~120000-150000 газа) из-за SLOAD/SSTORE внутри пула. Но для MEV-ботов, выполняющих тысячи свопов в день, экономия ~2500 газа за своп — это существенная прибыль.

Безопасность контракта

Наш контракт имеет несколько важных ограничений:

  1. Нет проверки переполнения — расчёт amountInWithFee * reserveOut может переполниться для очень больших значений. В продакшене используйте mulmod или проверки.
  2. ФронтраннингamountOutMin защищает от слишком большого проскальзывания, но не от сэндвич-атак.
  3. Нет deadline — в продакшене добавьте проверку timestamp < deadline.
  4. Одноразовый approve — для максимальной безопасности используйте approve на точную сумму.

Деплой и тестирование

Для компиляции и деплоя standalone Yul-контракта:

# Компиляция с solc
solc --strict-assembly --optimize --optimize-runs 200 swap.yul

# Тестирование с Foundry
forge test -vvvv --match-contract SwapTest

Итоги серии

За восемь статей серии Deep EVM мы прошли путь от базовых опкодов до полноценного контракта на Yul:

  1. Опкоды, стек, газ — фундамент EVM
  2. Модель памяти — стек, память, хранилище, calldata
  3. Экономика газа — стоимость операций и оптимизация
  4. Безопасность — msg.sender, реентрабельность, CEI
  5. Введение в Yul — синтаксис и встроенные функции
  6. Управление памятью — mstore, mload, свободный указатель
  7. Циклы и условия — газоэффективные паттерны
  8. Своп на Yul — применение всех концепций

Эти знания — фундамент для серьёзной разработки смарт-контрактов, аудита безопасности и создания MEV-ботов. Понимание EVM на уровне опкодов отличает хорошего разработчика от великого.