Раздел 30 · Web3 на Rust

Строим DEX: пул на Solidity и тесты, которые ловят атаку

senior~60 мин

без входаВойди, чтобы отмечать прогресс, вести заметки и решать задачи в редакторе. войти

Строим DEX: пул на Solidity и тесты, которые ловят атаку

Два урока подряд мы считали пул на Rust: инвариант и своп, потом диапазоны. Теперь тот же инвариант переезжает туда, где ему положено жить, в контракт. Урок проектный: к концу у тебя собирается пул, который принимает ликвидность, обменивает токены, выдаёт флеш-свопы и держит удар от живого атакующего. Проверять будем не глазами, а forge test, включая фаззинг инварианта.

Каркас проекта

Работаем в Foundry, который мы уже трогали в уроке про инструментарий и в уроке про безопасность.

forge init our-dex-contracts
cd our-dex-contracts
forge test

Раскладка простая: контракты в src/, тесты в test/, зависимости в lib/. Всё, что ниже, кладётся в два файла: src/OurPool.sol и test/OurPool.t.sol.

Интерфейсы и хранилище

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;

interface IERC20 {
    function transfer(address to, uint256 amount) external returns (bool);
    function transferFrom(address from, address to, uint256 amount) external returns (bool);
    function balanceOf(address account) external view returns (uint256);
}

/// @notice Получатель флеш-свопа: пул зовёт этот метод, отдав токены, но не
/// закончив своп. Вернуть долг обязан именно этот вызов.
interface IFlashCallback {
    function onFlashSwap(address caller, uint256 amount0, uint256 amount1, bytes calldata data)
        external;
}

contract OurPool {
    /// @notice Доли, сжигаемые навсегда при первом вкладе.
    uint256 public constant MINIMUM_LIQUIDITY = 1000;
    /// @notice Комиссия свопа в базисных пунктах.
    uint256 public constant FEE_BPS = 30;
    uint256 private constant BPS = 10_000;

    IERC20 public immutable token0;
    IERC20 public immutable token1;

    uint256 public reserve0;
    uint256 public reserve1;
    uint256 public totalShares;
    mapping(address => uint256) public sharesOf;

    /// @dev 1 это «свободно», 2 это «внутри вызова». Целые вместо bool, чтобы
    /// не платить за переход слота из нуля в единицу на каждом свопе.
    uint256 private _entered = 1;

    event Mint(address indexed to, uint256 amount0, uint256 amount1, uint256 shares);
    event Burn(address indexed from, uint256 amount0, uint256 amount1, uint256 shares);
    event Swap(address indexed sender, bool zeroForOne, uint256 amountIn, uint256 amountOut);
    event Flash(address indexed sender, uint256 amount0, uint256 amount1);

    error Reentrant();
    error ZeroAmount();
    error InsufficientLiquidity();
    error InsufficientShares();
    error SlippageExceeded(uint256 got, uint256 min);
    error KInvariantBroken();

    modifier lock() {
        if (_entered != 1) revert Reentrant();
        _entered = 2;
        _;
        _entered = 1;
    }

    constructor(IERC20 _token0, IERC20 _token1) {
        token0 = _token0;
        token1 = _token1;
    }

Четыре решения видны прямо здесь.

immutable для адресов токенов. Значение зашивается в код контракта при деплое, и чтение стоит как константа, а не как обращение к хранилищу. Пул привязан к паре навсегда, и это правильно: смена токена под ногами у поставщиков означала бы кражу.

Пользовательские ошибки вместо строк. error SlippageExceeded(uint256 got, uint256 min) дешевле строки в require и, что важнее, машиночитаема: тест ловит её по селектору, а не по тексту.

Замок целым числом, а не булевым. Изменение слота с нуля на ненулевое стоит заметно дороже, чем изменение ненулевого на другое ненулевое. Начав с единицы, мы платим меньше на каждом свопе. Приём взят из практики, и он один из немногих случаев, когда микрооптимизация оправдана.

Комиссия константой. Пул с фиксированной комиссией проще рассуждать: нет управляющего, который может её поднять.

Расчёт свопа

    /// @notice Выход свопа по формуле пула. Комиссия снимается со входа,
    /// деление округляется вниз: остаток остаётся в пуле.
    function getAmountOut(uint256 amountIn, uint256 reserveIn, uint256 reserveOut)
        public
        pure
        returns (uint256)
    {
        if (amountIn == 0) revert ZeroAmount();
        if (reserveIn == 0 || reserveOut == 0) revert InsufficientLiquidity();
        uint256 netIn = amountIn * (BPS - FEE_BPS);
        return (netIn * reserveOut) / (reserveIn * BPS + netIn);
    }

Та же формула, что на Rust в уроке 46, только без checked_mul: начиная с версии 0.8 Solidity проверяет переполнение сам и откатывает транзакцию. Функция pure, поэтому её можно звать снаружи бесплатно и считать котировку до отправки.

Внесение ликвидности

    function addLiquidity(uint256 amount0, uint256 amount1)
        external
        lock
        returns (uint256 minted)
    {
        if (amount0 == 0 || amount1 == 0) revert ZeroAmount();

        if (totalShares == 0) {
            uint256 root = _sqrt(amount0 * amount1);
            if (root <= MINIMUM_LIQUIDITY) revert InsufficientLiquidity();
            minted = root - MINIMUM_LIQUIDITY;
            totalShares = MINIMUM_LIQUIDITY;
        } else {
            uint256 by0 = (amount0 * totalShares) / reserve0;
            uint256 by1 = (amount1 * totalShares) / reserve1;
            minted = by0 < by1 ? by0 : by1;
        }
        if (minted == 0) revert ZeroAmount();

        // Эффекты до взаимодействий: состояние согласовано ещё до того, как
        // управление уйдёт в код токена.
        reserve0 += amount0;
        reserve1 += amount1;
        totalShares += minted;
        sharesOf[msg.sender] += minted;

        token0.transferFrom(msg.sender, address(this), amount0);
        token1.transferFrom(msg.sender, address(this), amount1);
        emit Mint(msg.sender, amount0, amount1, minted);
    }

Обрати внимание на порядок: сначала мы правим всё состояние, и только потом зовём внешний код. Это правило checks-effects-interactions, и оно спасает даже тогда, когда про замок забыли.

Токен это чужой контракт, а transferFrom это вызов чужого кода. Считать, что он просто передвинет числа, нельзя: он может позвать тебя обратно, и мы это сейчас проверим тестом.

Изъятие ликвидности и своп

    function removeLiquidity(uint256 shares)
        external
        lock
        returns (uint256 amount0, uint256 amount1)
    {
        if (shares == 0) revert ZeroAmount();
        if (sharesOf[msg.sender] < shares) revert InsufficientShares();

        amount0 = (shares * reserve0) / totalShares;
        amount1 = (shares * reserve1) / totalShares;
        if (amount0 == 0 || amount1 == 0) revert ZeroAmount();

        sharesOf[msg.sender] -= shares;
        totalShares -= shares;
        reserve0 -= amount0;
        reserve1 -= amount1;

        token0.transfer(msg.sender, amount0);
        token1.transfer(msg.sender, amount1);
        emit Burn(msg.sender, amount0, amount1, shares);
    }

    /// @notice Обменять один токен на другой с защитой по минимальному выходу.
    /// @param minAmountOut граница, ниже которой своп не нужен. Без неё
    /// транзакция беззащитна перед сэндвичем: её исполнят по любой цене.
    function swap(bool zeroForOne, uint256 amountIn, uint256 minAmountOut)
        external
        lock
        returns (uint256 amountOut)
    {
        (uint256 reserveIn, uint256 reserveOut) =
            zeroForOne ? (reserve0, reserve1) : (reserve1, reserve0);
        amountOut = getAmountOut(amountIn, reserveIn, reserveOut);
        if (amountOut < minAmountOut) revert SlippageExceeded(amountOut, minAmountOut);

        if (zeroForOne) {
            reserve0 += amountIn;
            reserve1 -= amountOut;
        } else {
            reserve1 += amountIn;
            reserve0 -= amountOut;
        }

        (IERC20 tokenIn, IERC20 tokenOut) = zeroForOne ? (token0, token1) : (token1, token0);
        tokenIn.transferFrom(msg.sender, address(this), amountIn);
        tokenOut.transfer(msg.sender, amountOut);
        emit Swap(msg.sender, zeroForOne, amountIn, amountOut);
    }

Параметр minAmountOut не удобство, а обязательная часть интерфейса. Пользователь считает котировку в момент отправки, а исполняется транзакция через несколько секунд и после чужих сделок. Без этой границы любой своп это подпись под фразой «согласен на любую цену», а желающие такой подписью воспользоваться описаны в уроке про MEV.

премиум-урок

Продолжение в премиуме: 1 200 ₽ в месяц или 10 000 ₽ в год. Вместе с ним серверные проверки задач и школьные токены AI-коуча.

Оплата пока не подключена: премиум и курсы открываются промокодом.