В этой статье я разбираю, как работает CREATE2, чем он отличается от CREATE, зачем нужен в контексте state channels и ERC-4337, и как его можно использовать — от вычисления адресов в Uniswap V2 до взлома Tornado Cash на $1M. Показываю примеры на Solidity и Assembly, а ещё — как на одном и том же адресе можно развернуть два разных смарт-контракта. Да, и такое возможно.

Опкод CREATE

Раньше было лучше — ну или, по крайней мере, надежнее. Так можно сказать про опкод CREATE, предшественника CREATE2. Он был прост и не создавал проблем (возможных уязвимостей).

На самом деле, опкод CREATE никуда не делся — он используется всегда, когда смарт-контракт создается через ключевое слово new:

contract Bar {
    // @notice Создание смарт-контракта через create без отправки ETH на новый адрес
    function createFoo() external returns (address) {
        Foo foo = new Foo();

        return address(foo);
    }

    // @notice Создание смарт-контракта через create с отправкой ETH на новый адрес
    function createBaz() external payable returns (address) {
        Baz baz = new Baz{value: msg.value}();

        return address(baz);
    }
}

Полный код контракта приведен здесь.

Опкод CREATE принимает три аргумента и возвращает одно значение:

Входные данные (Stack input):

  • value — количество нативной валюты в wei, которое будет отправлено на новый адрес.

  • offset — смещение байтов, с которого начинается код инициализации контракта.

  • size — размер кода инициализации.

Выходные данные (Stack output):

  • address — адрес развернутого смарт-контракта либо 0, если произошла ошибка.

Развертывание смарт-контракта через assembly выглядит нагляднее:

contract Deployer {
    // @notice Создание смарт-контракта через create без отправки wei на новый адрес
    function deployFoo() public returns (address) {
        address foo;
        bytes memory initCode = type(Foo).creationCode;

        assembly {
            // Загружаем код инициализации в память
            let codeSize := mload(initCode)  // Размер кода инициализации
            let codeOffset := add(initCode, 0x20)  // Пропускаем 32 байта, содержащие длину массива initCode

            // Вызываем CREATE без отправки msg.value
            foo := create(0, codeOffset, codeSize)
            // Проверяем, что контракт был успешно создан
            if iszero(foo) { revert(0, 0) }
        }

        return foo;
    }
}

Полный код смарт-контракта с созданием через assembly — здесь.

Вычисление адреса опкодом CREATE (0xf0)

Чтобы опкод CREATE мог вернуть адрес развернутого контракта, ему необходимы адрес вызывающей стороны (msg.sender) и ее nonce:

В упрощенном виде это выглядит так:

address = hash(sender, nonce)

Но на самом деле процесс сложнее:

address = keccak256(rlp([sender_address, sender_nonce]))[12:]

Где:

  • sender_address — адрес отправителя, создающего контракт.

  • sender_nonce — nonce отправителя (количество транзакций, отправленных с этого адреса).

  • rlp — функция RLP-кодирования. RLP (Recursive Length Prefix) используется для сериализации данных в Ethereum, обеспечивая однозначное и предсказуемое кодирование.

  • keccak256 — хеш-функция Keccak-256.

  • [12:] — первые 12 байт отбрасываются, поскольку keccak256 возвращает 32 байта, а адрес в Ethereum занимает последние 20 байт хеша (32 - 20 = 12).

Таким образом, в теории можно вычислить адрес будущего смарт-контракта заранее. Однако есть проблема: этот адрес зависит от nonce. Если перед развертыванием смарт-контракта будет отправлена другая транзакция, nonce увеличится, и вычисленный адрес станет недействительным.

Из-за использования RLP для вычисления адреса в Solidity перед развертыванием необходима следующая громоздкая функция:

function computeAddressWithCreate(uint256 _nonce) public view returns (address) {
    address _origin = address(this);
    bytes memory data;

    if (_nonce == 0x00) {
        data = abi.encodePacked(bytes1(0xd6), bytes1(0x94), _origin, bytes1(0x80));
    } else if (_nonce <= 0x7f) {
        data = abi.encodePacked(bytes1(0xd6), bytes1(0x94), _origin, uint8(_nonce));
    } else if (_nonce <= 0xff) {
        data = abi.encodePacked(bytes1(0xd7), bytes1(0x94), _origin, bytes1(0x81), uint8(_nonce));
    } else if (_nonce <= 0xffff) {
        data = abi.encodePacked(bytes1(0xd8), bytes1(0x94), _origin, bytes1(0x82), uint16(_nonce));
    } else if (_nonce <= 0xffffff) {
        data = abi.encodePacked(bytes1(0xd9), bytes1(0x94), _origin, bytes1(0x83), uint24(_nonce));
    } else {
        data = abi.encodePacked(bytes1(0xda), bytes1(0x94), _origin, bytes1(0x84), uint32(_nonce));
    }
    return address(uint160(uint256(keccak256(data))));
}

Длина всего кодирования зависит от того, сколько байт нужно для кодирования nonce, так как адрес имеет постоянную длину 20 байт, отсюда и много if.

Например, если nonce 0, то параметры значат следующее:

  • 0xd6 - длина всей структуры 22 байта (в случае с nonce равным 0).

  • bytes1(0x94) - означает, что дальше идет поле длиной в 20 байт.

  • _origin - поле адреса.

  • bytes1(0x80) означает, что nonce равен 0, согласно RLP.

Остальное аналогично, только добавляется nonce, как один байт и так далее. То есть в кодировке RLP важно явно указывать длину данных перед самими данными.

Я добавил эту функцию смарт-контракту Deployer — можете протестировать в Remix.

Мы с коллегами периодически пишем в нашем Telegram-канале. Иногда это просто мысли вслух, иногда какие-то наблюдения с проектной практики. Не всегда всё оформляем в статьи, иногда проще написать пост в телегу. Так что, если интересно, что у нас в работе и что обсуждаем, можете заглянуть.

Предпосылки создания CREATE2

В 2018 году Виталик Бутерин предложил EIP-1014: Skinny CREATE2 со следующей мотивацией:

"Позволяет проводить взаимодействия (фактически или контрфактически в каналах) с адресами, которые еще не существуют на блокчейне, но могут быть использованы, предполагая, что в будущем они будут содержать код, созданный определенным кодом инициализации. Важно для случаев использования каналов состояния, связанных с контрфактическими взаимодействиями с контрактами."

Звучит сложно, но попробую объяснить. Дело в state channels. До появления rollups они рассматривались как способ масштабирования Ethereum.

Если коротко, в каналах состояния существовали неэффективности, которые можно было устранить с помощью counterfactual instantiation. Суть в том, что смарт-контракт мог существовать контрфактически — то есть его не нужно было развертывать, но его адрес был известен заранее.

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

Пример из описания механизма:

"Представьте платежный канал между Алисой и Бобом. Алиса отправляет Бобу 4 ETH через канал, подписав соответствующую транзакцию. Эта транзакция может быть развернута ончейн в любой момент, но этого не происходит. Таким образом, можно сказать: 'Контрфактически Алиса отправила Бобу 4 ETH'. Это позволяет им действовать так, как будто транзакция уже состоялась — она окончательная в рамках заданных моделей угроз."

То есть, по теории игр, зная, что существует такая "страховка", стороны не будут пытаться обмануть друг друга, а сам контракт, скорее всего, так и не придется развертывать.

Подробнее об этом можно почитать здесь и здесь, но тема непростая — я вас предупредил.

Как работает опкод CREATE2 (0xf5)

Опкод CREATE2 был введен в хардфорке Константинополь как альтернатива CREATE. Главное отличие — способ вычисления адреса создаваемого контракта. Вместо nonce деплойера используется код инициализации (creationCode) и соль (salt).

Новая формула вычисления адреса:

address = keccak256(0xff + sender_address + salt + keccak256(initialisation_code))[12:]
  • 0xff — префикс, предотвращающий коллизии с адресами, созданными через CREATE. В RLP-кодировке 0xff может использоваться только для данных петабайтного размера, что нереалистично в EVM. Дополнительно keccak256 защищает от коллизий.

  • sender_address — адрес отправителя, создающего смарт-контракт.

  • salt — 32-байтовое значение, обычно keccak256 от некоторого набора данных, который обеспечивает уникальность этой соли.

  • initialisation_code — код инициализации смарт-контракта.

Важно! Если CREATE или CREATE2 вызывается в транзакции создания и адрес назначения уже содержит ненулевой nonce или непустой code, создание немедленно завершается (revert), аналогично ситуации, когда первый байт initialisation_code — недействительный опкод.

Это означает, что если при деплое случится коллизия адреса с уже существующим смарт-контрактом (например, развернутым через CREATE), произойдет revert, так как nonce адреса уже ненулевой. Это поведение нельзя изменить даже через SELFDESTRUCT, так как он не сбрасывает nonce в той же транзакции.

По сравнению с CREATECREATE2 отличается лишь добавлением одного параметра на входе — salt.

Входные данные (Stack input):

  • value — количество нативной валюты (wei) для отправки на новый адрес.

  • offset — смещение байтов, с которого начинается код инициализации.

  • size — размер кода инициализации.

  • salt — 32-байтовое значение, используемое при создании контракта.

Выходные данные (Stack output):

  • address — адрес развернутого контракта или 0, если произошла ошибка.

Использование CREATE2 в Solidity

В Solidity CREATE2 можно использовать так же, как CREATE, просто добавив salt:

contract DeployerCreate2 {
    /// @notice Создание смарт-контракта через create2 без отправки wei
    function create2Foo(bytes32 _salt) external returns (address) {
        Foo foo = new Foo{salt: _salt}();
        return address(foo);
    }

    /// @notice Создание смарт-контракта через create2 с отправкой wei
    function create2Bar(bytes32 _salt) external payable returns (address) {
        Bar bar = new Bar{value: msg.value, salt: _salt}();
        return address(bar);
    }
}

Полный код контракта здесь.

Важно! Опкоды CREATE и CREATE2 используются только для создания смарт-контрактов из других смарт-контрактов. При первоначальном развертывании смарт-контракта все происходит совсем иначе - в поле транзакции to записывается nil (аналог null), а его фактическое создание выполняется опкодом RETURN в creationCode, а не CREATE.

CREATE2 с помощью Assembly

Пример кода на Assembly (взято из Cyfrin):

function deploy(bytes memory bytecode, uint256 _salt) public payable {
    address addr;

    /*
    NOTE: Как вызвать create2

    create2(v, p, n, s)
    создает новый смарт-контракт с кодом в памяти от p до p + n
    и отправляет v wei
    и возвращает новый адрес
    где новый адрес = первые 20 байт keccak256(0xff + address(this) + s + keccak256(mem[p…(p+n)]))
          s = big-endian 256-битное значение
    */
    assembly {
        addr :=
            create2(
                callvalue(),       // wei, отправленный с вызовом
                add(bytecode, 0x20),  // Код начинается после первых 32 байт (длина массива)
                mload(bytecode),   // Размер кода (первые 32 байта)
                _salt              // Соль
            )

        if iszero(extcodesize(addr)) { revert(0, 0) }
    }

    emit Deployed(addr, _salt);
}

Полный код контракта здесь.

Траты на газ

Ранее, при вычислении адреса через CREATE, использовались только address и nonce, занимающие не более 64 байт. Поэтому дополнительная плата за вычисления не взималась (см. evm.codes).

В CREATE2 добавилось вычисление хеша от кода инициализации (hash_cost), так как его размер может сильно варьироваться. Это изменило формулу расчета газа:

minimum_word_size = (size + 31) / 32
init_code_cost = 2 * minimum_word_size
hash_cost = 6 * minimum_word_size
code_deposit_cost = 200 * deployed_code_size

static_gas = 32000
dynamic_gas = init_code_cost + hash_cost + memory_expansion_cost + deployment_code_execution_cost + code_deposit_cost

Таким образом, использование CREATE2 обходится дороже CREATE, но дает возможность более гибко работать с адресом смарт-контракта до его создания, что открывает новые возможности.

Преимущества CREATE2

Что дало введение нового опкода?

  1. Контрфактическая инициализация
    CREATE2 позволяет резервировать адреса смарт-контрактов до их фактического развертывания. Это особенно полезно в каналах состояния, о которых мы говорили ранее.

  2. Упрощение онбординга пользователей
    В контексте абстракции аккаунтов контрфактическая инициализация позволяет создавать аккаунты оффчейн и развертывать их только при первой транзакции, которая к тому же может быть оплачена через релейный сервер. Это делает создание абстрактного аккаунта проще, чем создание EOA.

    В момент появления CREATE2 это было лишь идеей, но спустя три года концепция была реализована в ERC-4337. Для этого используется статический вызов entryPoint.getSenderAddress(bytes initCode), который позволяет получить контрфактический адрес кошелька до его создания.

  3. Vanity-адреса
    Можно подобрать "красивый" адрес, перебирая salt, например, если хотите, чтобы он начинался или заканчивался на определенные символы: 0xC0FFEE...0xDEADBEEF... и т. д.

  4. Эффективные адреса
    В EVM стоимость нулевых и ненулевых байт различается. За каждый ненулевой байт calldata взимается G_txdatanonzero (16 газа), а за нулевой — G_txdatazero (4 газа). Это значит, что если ваш адрес начинается с нулей, его использование будет дешевле.

    Здесь подробно разобран этот аспект: On Efficient Ethereum Addresses (хотя расчеты по газу уже устарели из-за изменения стоимости calldata).

  5. Метаморфичные контракты
    Способ обновления смарт-контрактов через CREATE2, при котором смарт-контракт уничтожается (SELFDESTRUCT) и создается заново с тем же адресом, но с новым кодом. К счастью сообщество не приняло этот подход, например, в этой статье его называют "уродливым сводным братом Transparent Proxy".

    Примеры кода можно посмотреть здесь.

  6. Вычисление адреса вместо хранения
    В ряде случаев проще вычислить адрес контракта, развернутого через CREATE2, чем хранить его. Яркий пример — Uniswap v2.

    Как это работает?

    • Для создания пар через UniswapV2Factory используется CREATE2, а в качестве соли используются адреса двух токенов в паре. Обратите внимание, что смарт-контракт пары использует функцию initialize для сохранения адресов токенов, это важный момент.

      function createPair(address tokenA, address tokenB) external returns (address pair) {
          /// ...
          bytes memory bytecode = type(UniswapV2Pair).creationCode;
          bytes32 salt = keccak256(abi.encodePacked(token0, token1));
          assembly {
              pair := create2(0, add(bytecode, 32), mload(bytecode), salt)
          }
          IUniswapV2Pair(pair).initialize(token0, token1);
          /// ...
      }
    • Теперь библиотека UniswapV2Library может вычислять адрес пары, используя заранее известный init code hash в функции pairFor. При этом код инициализации можно просто захардкодить, потому что нам не нужно добавлять аргументы конструктора (именно поэтому используется initialize):

      function pairFor(address factory, address tokenA, address tokenB) internal pure returns (address pair) {
          (address token0, address token1) = sortTokens(tokenA, tokenB);
          pair = address(uint(keccak256(abi.encodePacked(
              hex'ff',
              factory,
              keccak256(abi.encodePacked(token0, token1)),
              hex'96e8ac4277198ff8b6f785478aa9a39f403cb768dd02cbee326c3e7da348845f' // init code hash
          ))));
      }

      ⚠️ Если форкаете Uniswap v2, не забудьте поменять init code hash, так как он зависит от фабрики (factory), адрес которой устанавливается в конструкторе смарт-контракта пары.

    • Ну и теперь имея функцию pairFor можно спокойно вычислять этот адрес когда необходимо. Только посмотрите как часто эта функция используется в UniswapV2Router01. К примеру так выглядит функция добавления ликвидности:

      function addLiquidity(
          address tokenA,
          address tokenB,
          uint amountADesired,
          uint amountBDesired,
          uint amountAMin,
          uint amountBMin,
          address to,
          uint deadline
      ) external override ensure(deadline) returns (uint amountA, uint amountB, uint liquidity) {
          (amountA, amountB) = _addLiquidity(tokenA, tokenB, amountADesired, amountBDesired, amountAMin, amountBMin);
          address pair = UniswapV2Library.pairFor(factory, tokenA, tokenB);
          ///...
      }

Как видно, CREATE2 открыл множество новых возможностей, хотя у него есть и недостатки.

Уязвимость CREATE2

В документации Solidity можно встретить следующее предупреждение:

"Создание солей имеет некоторые особенности. Контракт может быть повторно создан по тому же адресу после того, как он был уничтожен. При этом вновь созданный контракт может иметь другой развернутый байткод, даже если байткод создания был тем же самым (что является обязательным условием, поскольку в противном случае адрес изменился бы). Это связано с тем, что конструктор может запросить внешнее состояние, которое могло измениться между двумя созданиями, и включить его в развернутый байткод до того, как он будет сохранен."

Речь идет о хорошо известной уязвимости: комбинация CREATE и CREATE2 в сочетании с SELFDESTRUCT позволяет развернуть на одном и том же адресе разные контракты. Именно этот метод использовали при взломе Tornado Cash, когда был украден $1M.

Это также касается метаморфических контрактов.

Демонстрация атаки

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

Контракт фабрики:

contract Factory {
    function createFirst() public returns (address) {
        return address(new First());
    }

    function createSecond(uint256 _number) public returns (address) {
        return address(new Second(_number));
    }

    function kill() public {
        selfdestruct(payable(address(0)));
    }
}

Эта фабрика создает смарт-контракты с идентичными адресами, но разным кодом. ?

Шаг 1. Деплоим MetamorphicContract и вызываем функцию firstDeploy:

function firstDeploy() external {
    factory = new Factory{salt: keccak256(abi.encode("evil"))}();
    first = First(factory.createFirst());

    emit FirstDeploy(address(factory), address(first));

    first.kill();
    factory.kill();
}

Этот вызов:

  • Деплоит фабрику и первую версию смарт-контракта.

  • Уничтожает их сразу после развертывания.

  • Логирует их адреса.

  • Уничтожает оба контракта.

Результат в Remix:

Remix logs
Remix logs

Шаг 2. Теперь можно вызывать функцию secondDeploy:

function secondDeploy() external {
    /// Проверяем, что контракты удалены
    emit CodeLength(address(factory).code.length, address(first).code.length);

    /// Деплоим фабрику на тот же адрес
    factory = new Factory{salt: keccak256(abi.encode("evil"))}();

    /// Деплоим новый контракт на тот же адрес, что и первый
    second = Second(factory.createSecond(42));

    /// Проверяем, что адреса совпадают
    require(address(first) == address(second));

    /// Выполняем логику нового контракта
    second.setNumber(21);

    /// Логируем адреса
    emit SecondDeploy(address(factory), address(second));
}

Результат в Remix:

Remix logs
Remix logs

Что здесь произошло?

  1. Развернули фабрику через CREATE2 с фиксированной солью.

  2. Фабрика через CREATE создала контракт-имплементацию. Ее адрес зависит от адреса фабрики и nonce.

  3. Уничтожили оба контракта (SELFDESTRUCT). Это обнулило nonce фабрики.

  4. Развернули ту же фабрику по тому же адресу (так как соль не изменилась).

  5. Развернули другую имплементацию по тому же адресу, так как nonce фабрики снова 0.

  6. Теперь на одном и том же адресе — совершенно другой код!

Полный код контракта тут.

Теперь вы знаете, как можно развернуть разные смарт-контракты на одном адресе. ?

Ссылки

Комментарии (0)