June 5, 2022

MEV 1.1

Всем привет! С вами Тёма!

Сегодня мы начнем погружаться в MEV и попытаемся понять что это такое и почему оно на слуху

Перевод данной статьи - ТЫК

Начнем!

На дворе апрель 2021 года, серчеры MEV бродят по сети в поисках удачи, повсюду снайперы, PvP-сражения накаляются

Новый участник вот-вот вступит в бой

На блоке 12342861 из тени появился Серчер. Он пришел ни с чем и сделал свой первый выстрел

Это история о том, как MevAlphaLeak вышел на арену MEV

Глазами Серчера

Изучение этой истории и последующее глубокое погружение дает нам возможность увидеть мир глазами Серчера в тот момент времени

Наша задача такая же, как у механика, который разбирает машину, чтобы посмотреть, как она работает. Выполняя обратный инжиниринг составных частей, механик может начать понимать мыслительный процесс инженера/проектировщика

Это гораздо более ценно, чем простое копирование стратегии. По мере того, как вы собираете эти точки зрения серчеров, вы можете строить и внедрять инновации на основе идей, которые пришли к вам

Что случилось?

Мы начнем с общего обзора того, что произошло в блоке 12342861, а затем подробно рассмотрим конкретную часть контракта

Первые транзакции MevAlphaLeak расположены в верхней части блока 12342861, давайте начнем расследование с него

  1. В этом разделе показаны первые 2 транзакции из блока 12342861 на Etherscan. Первый создает контракт ApeBot, а второй взаимодействует с этим контрактом. Мы видим, что mevalalphaleak.eth — это EOA, инициировавший транзакции. Обратите внимание, что mevalphaleak.eth — это совершенно новый адрес с 0 ETH в аккаунте
  2. Смотрим созданный контракт ApeBot на Etherscan. Адрес контракта — 0x666f80a198412bCb987c430831B57AD61facB666. Обратите внимание, что исходный код контракта был верифнут, поэтому мы можем видеть код Solidity. Обычно серчеры не верифают свои контракты, чтобы скрыть происходящее
  3. Вторая транзакция в блоке взаимодействует с контрактом ApeBot. Обратите внимание, что в разделе «interact with (to)» в качестве точки входа указан адрес контракта ApeBot 0x666f80a198412bCb987c430831B57AD61facB666
  4. Глядя на переданные токены, мы получаем приблизительное представление о том, что произошло. Флэшлоан был взят у dYdX, а затем использован в арбитраже между EtherDelta 2 и Uniswap V2 с токеном CST. Мы видим, что флэшлоан возвращается в конце
  5. В конце транзакции есть два интересных перевода. Первый в «Spark Pool» (0x5A0b54D5dc17e0AadC383d2db43B0a0D3E029c4c), большой Ethereum Mining Entity стоимостью ~ 0,4497 ETH. Второй — 0x90102a92e8E40561f88be66611E5437FEb339e79, что соответствует ENS mevalphaleak.eth за ~0,0004 ETH. Вы скоро узнаете актуальность этих транзакций
  6. Цена газа транзакции ​​равна нулю (транзакция создания контракта также имела нулевую цену газа). В этот момент вы должны задаться вопросом, почему любой майнер принимает транзакцию с нулевым газом

Причина, по которой майнер принимает эти транзакции, связана с переводом ~ 0,4497 ETH в [5] в Spark Pool. Давайте посмотрим, кто был майнером блока 12342861

Мы видим, что блок 12342862 был добыт Spark Pool. Тот же «Spark Pool», в который был выплата во второй транзакции mevalalphaleak.eth. Что происходит, что позволяет счету с 0 ETH совершать несколько транзакций с нулевым газом?

Секрет в том, что эти 2 транзакции не пошли традиционным путем в мемпул, чтобы их забрал майнер. Вместо этого они прошли через flashbot relay

Flashbot relay

Я не буду вдаваться в детали Flashbots Relay, однако приведу краткий обзор для целей этой статьи

Когда серчеры выполняют стратегии MEV, им часто нужно выполнять несколько транзакций одну за другой, и они обычно хотят быть в верхней части блока

Их поиск основан на состоянии мира в блоке n-1, где n — это блок, в который они хотят быть включены. Блок Ethereum может иметь сотни транзакций, каждая из которых изменяет world state

Представьте, что «Серчер X» и «Серчер Y» видят одну и ту же возможность арбитража и оба отправляют арбитражные транзакции. Когда транзакции блока выполняются, они выполняются процедурно (по порядку)

Если транзакция Серчера X выше транзакции Серчера Y, то, когда нода доберется до вычисления транзакции Серчера Y, состояние Ethereum изменится. Возможности арбитража больше не будет, и сделка либо вернется, либо станет убыточной

Вот почему так важно контролировать порядок транзакций в блоке

Как я уверен, вы знаете, что упорядочение транзакций в блоке является важным компонентом MEV. До Flashbots Relay это обычно делалось через PGA (приоритетные газовые аукционы) или если ты майнер

Flashbots увидели эту проблему и искали решение. Они изменили кодовую базу Geth, чтобы создать mev-geth, клиент Ethereum, который позволял серчерам отправлять пакеты транзакций майнерам через Flashbots Relay

Майнер смотрел, какое предложение принесло ему наибольшую награду, и помещал этот пакет в верхнюю часть блока. Это вознаграждение может быть выплачено газом или с помощью «coinbase.transfer», который отправляет указанное количество ETH майнеру блока

В отличие от мемпула, relay доступно всем, кроме флешботов. Вы можете подписывать несколько транзакций / пакетов, и вы не будете платить за газ, если они не будут выполнены

Классной особенностью Flashbots Relay является их API, который показывает нам для каждого блока, были ли включены какие-либо пакеты через relay

Давайте посмотрим на блок 12342861

Мы видим, что транзакции MevAlphaLeaks проходили через Flashbots Relay, что объясняет, почему он смог использовать нулевой адрес и coinbase.transfer в Spark Pool

Хорошо, давайте сделаем краткий обзор того, что мы узнали, прежде чем углубиться окончательно:

· MevAlphaLeak отправил пакет транзакций через ретранслятор flashbots

· Первая транзакция создала контракт ApeBot

· Вторая транзакция взаимодействовала с контрактом ApeBot для арбитража

· Арбитраж сделал следующее (обратите внимание, что я пропустил преобразования WETH / ETH, которые происходят во время арбитража)

  • Flashloan из dYdX в WETH
  • Обмен WETH на CST на EtherDelta 2
  • Обмен CST на ETH на Uniswap V2
  • Возврат Flashloan WETH в dYdX

· В конце второй транзакции MevAlphaLeak платит майнеру по методу conbase.transfer 0,449293093766000097 ETH

· MevAlphaLeak выходит с контрактом, развернутым бесплатно, и 0,000449742836602602 ETH

Теперь давайте углубимся в техничку контракта ApeBot

ApeBot Contract

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

Мы можем начать с общего обзора двиджения контракта и определить, где находится ассемблерный код, который мы хотели бы проверить. Обратите внимание, что все фрагменты кода взяты из верефнутого контракта на Etherscan, поэтому номера строк должны совпадать с картинками

  1. Начнем с вызова wfjizxua(uint256,uint256[]). Если вам интересно, почему такое странное имя, то попробуйте хешировать его, чтобы получить сигнатуру функции. Он возвращает сигнатуру 0x00000000, которая более эффективна в отношении газа, чем сигнатура функции, которая не равна нулю (нулевой байт в calldata стоит 4 газа, тогда как ненулевой байт стоит 16 газа). Мы передаем в функцию actionFlags и actionData
  2. Эти данные (actionFlags и actionData), в свою очередь, передаются в ape(uint256,uint256[]). Это функция без ограничений, которая позволяет пользователю выполнять любую произвольную логику. Это означает, что у вас может быть один контракт и таргетинг на различные DEX на основе вашей calldata, а не создание индивидуальных контрактов для каждого из них. Вы увидите, как эта функция обобщается далее в статье
  3. Переменная «data» в функции ape, которая соответствует «actionData» из исходного вызова wfjizxua, зацикливается. Некоторый ассемблерный код выполняется в каждом цикле. Давай посмотрим что происходит

Во-первых, нам нужно просмотреть calldata для вызова контракта ApeBot

Как расшифровать, что означают эти данные? Мы можем начать с сигнатуры функции, которая сообщает нам, что uint256 и unit256[] были переданы

unit256[ ] является динамическим типом. Кодирование динамических типов отличается от статических типов. Для статических типов, таких как «uint256 actionFlags», мы передаем значения непосредственно в calldata. Мы можем видеть это значение в элементе calldata [0] на изображении выше

Для динамических типов, таких как «unit256[] actionData», мы передаем смещение в байтах до начала их области данных, измеренное от начала кодирования значения. В нашем случае это можно увидеть в элементе calldata [1] и имеет значение 0x40. Это равно 64 в десятичном виде и говорит нам, что область данных для массива начинается с 64 байтов в нашей calldata

Смещение в 64 байта означает, что элемент calldata [2] сигнализирует о начале области данных. Первый элемент в этой области данных [2] объявляет длину массива, 0x25 = 37 десятичных знаков

После этого у нас есть 37 пунктов от [3] до [39]. Эти элементы являются фактическими значениями в массиве uint256[]

Эти группировки полезны, но мы до сих пор не знаем, как используется эта calldata. Чтобы определить это, нам нужно посмотреть на ассемблерный код и работать оттуда в обратном направлении

Я выделил зеленым цветом первый элемент в фактическом массиве, calldata [3], будет понятно почему я так сделал, когда мы посмотрим на ассемблерный код

Ассемблерный код

Что может быть лучше для начала, чем первая строка ассемблерного кода. На изображении ниже это показано (строка 219) вместе с некоторыми выдержками из foundry debugger

В начале сборки переменная callInfo создается путем запуска MLOAD в ячейке памяти 0xa0. Данные в этой ячейке памяти = 100000000c78789120020674d380f7e1dc7408aa007744ed3af390f8a47f9b75, совпадают с элементом calldata [3], выделенным зеленым цветом в предыдущем разделе

Давайте теперь посмотрим, где эта переменная callInfo используется в остальной части кода

Выше приведен полный «ассемблерный код». Выделенные поля в ассемблерном коде показывают, где используется callInfo. Мы видим, что в callInfo используется несколько битовых масок для извлечения значений и выполнения логики на основе того, что возвращается. Если вы не знаете, как работают битовые маски, ознакомьтесь с "EVM для задротов 3", в которой рассматривается, как этот метод используется при упаковке слотов хранения

Глядя на сборку, мы можем определить, что это значение, 100000000c78789120020674d380f7e1dc7408aa007744ed3af390f8a47f9b75, на самом деле является пользовательским закодированным набором из 32 байтов

Ниже приведена его расшифровка. Обратите внимание, что номера ([1] и т. д.) на изображении ассемблерного кода выше соответствуют номерам на изображении ниже, чтобы вы могли ссылаться на них

1) callLength — кодируется в байтах 4 - 5, количество элементов для приема из calldata для последующего delegatecall / call

  • 0x000c = 12 в десятичной системе счисления

2) Сигнатура функции - кодируется в байтах 6 - 9, сигнатура функции для последующего delegatecall / call

  • 0x78789120, мы можем найти его в Ethereum Signature Database. Он ничего не возвращает, это указывает на то, что связанный контракт может быть не верефнутым

3) callContract — закодированный в байтах 13 - 32 адрес контракта для последующего delegatecall / call

4) Вызов delegatecall / call - кодируется в байте 1, должен ли последующий вызов быть DELEGATECALL или CALL

  • 0x10, выполнит как DELEGATECALL

5) Gas Amount for Call - закодировано в байтах 10 - 12, сколько газа выделено для последующего delegatecall / call

  • 0x020674 = 132724 в десятичной системе счисления

6) Должны ли возвращенные данные использоваться в следующем вызове - закодировано в байте 3, нужны ли возвращенные данные для следующего цикла

  • 0x00, возвращаемые данные не нужны

Чтобы укрепить наше понимание того, как используются эти закодированные значения, мы быстро пробежимся по логике, которая их использует и определяет путь выполнения

  1. Этот раздел кода проверяет ненулевую сигнатуру функции. Обратите внимание, как отличаются строки 226 и 234. Оба обновляют callLength, но когда сигнатура функции не равна нулю, к callLength добавляются дополнительные 4. Это для 4-байтовой сигнатуры функции. То же самое относится к строкам 228 и 236, обе предназначены для циклов for, которые зацикливаются на данных, но цикл for в строке 236 начинается с 4, а цикл в 228 начинается с 0. Опять же, это необходимо для учета 4-байтовой сигнатуры функции
  2. В этом разделе рассматривается callLength. Когда вы делаете DELEGATECALL или CALL, вы должны указать, где в памяти находятся calldata/input для этого вызова. Этот цикл for сохраняет элементы calldata в памяти в ожидании этого вызова. Он загружает количество элементов из calldata, указанных в закодированных данных. Если вы не уверены в том, что знаете как работает опкод DELEGATECALL, то взгляните на "EVM для задротов 5" в которой содержится подробный обзор
  3. В этом разделе рассматривается байт DELEGATECALL/CALL, если байт равен 0x10, то он выполняет delegatecall на «callContract», а для всего остального выполняется call
  4. Тут просматривается байт 3 и определяется, следует ли использовать возвращенные данные в следующем цикле. Чтобы его можно было использовать в следующем цикле, его нужно где-то сохранить. Если байт 3 не равен нулю, MSTORE используется для его сохранения в памяти, чтобы к нему можно было получить доступ позже

Теперь, когда мы понимаем кодировку callInfo и то, где она находится в calldata, мы можем вернуться к полной calldata для арбитражной транзакции

Мы собираемся организовать calldata по разделам на основе любой закодированной callinfo, которую мы сможем найти

Мы также собираемся связать эти разделы с parity traces, чтобы определить, какая calldata используется для какого вызова

Parity traces позволяет нам видеть все внутренние вызовы контракта для данной транзакции. Это чрезвычайно полезно при отладке и попытке собрать воедино то, что произошло в сети

Трассировки имеют traceAddress. TraceAddresses имеют обозначение [ 2, 1, 0, 3, 0, 2 ], где количество элементов в массиве показывает вам глубину вызова, а значение каждого элемента показывает вам индекс этого вызова на этой глубине (т.е. его положение ордера относительно других вызовов на этой глубине)

Вот краткий обзор обозначений с веб-сайта OpenEthereum (Parity)

Теперь давайте углубимся в calldata и связанные с ними parity traces. На изображении ниже многое происходит, но я обещаю, что это не слишком сложно

3 раздела в приведенных выше parity traces представляют 3 вызова, которые выполняют арбитраж между EtherDelta 2 и Uniswap V2

Данные вызова были сгруппированы, чтобы показать вам, какая calldata была передана top call в каждую из групп parity traces ([ 2, 1, 0, 3, 0, 2 ], [ 2, 1, 0, 3, 0, 3 ] & [ 2, 1, 0, 3, 0, 4 ])

В верхней части каждого раздела calldata находится закодированное пользователем значение «callInfo», см. элементы calldata [3], [17] и [31]. Группы были определены путем просмотра данных callInfo вместе с ассемблерным кодом

Parity traces показывают нам, что в каждом разделе выполняется несколько последовательных вызовов. Два в первом вызове, пять во втором вызове и десять в третьем вызове

Давайте подробнее рассмотрим 3 основных вызова

1) Первый вызов ([ 2, 1, 0, 3, 0, 2 ]) использует элемент calldata [3] в качестве закодированного callInfo. Мы рассмотрели именно этот callInfo ранее. Кодировка говорит нам сделать DELEGATECALL по адресу 0xd380f7e1dc7408aa007744ed3af390f8a47f9b75 и взять следующие 12 элементов из calldata в качестве входных данных

  1. Обратите внимание, что первый элемент после callInfo в calldata ([4]) равен 0x000…0. Это представляет собой сумму Wei для вызова. Вы не увидите его использования в потоке вызова делегата сборки, поскольку код операции DELEGATECALL не принимает значения. Однако вы можете увидеть, как к нему обращаются в вызовах в строке 260 ассемблерного кода. Обратите внимание, что это «значение Wei» не включено в «следующие 12 элементов» из calldata. Следовательно, «следующие 12 элементов» проходят от [5] до [16]. Это относится ко всем 3 основным вызовам

2) Если мы проверим parity traces в транзакциях на Etherscan, в частности, Action [17] TraceAddress [ 2, 1, 0, 3, 0, 2 ], мы увидим, что значения, закодированные в callInfo, соответствуют тому, что находится в трассировке

  1. Адрес «To» = 0xd380f7e1dc7408aa007744ed3af390f8a47f9b75
  2. CallType = delegatecall
  3. Газ = 132724 (выделенный газ в десятичном формате)
  4. Входные данные соответствуют 12 элементам calldata, выделенным префиксом сигнатуры закодированной функции

3) Второй вызов ([ 2, 1, 0, 3, 0, 3 ]) использует элемент calldata [17] в качестве callInfo. Он делает DELEGATECALL по тому же адресу 0xd380f7e1dc7408aa007744ed3af390f8a47f9b75, но с другой сигнатурой функции 0x401687f4. Опять же, следующие 12 элементов из calldata используются в качестве входных данных вместе с сигнатурой функции

4) Третий вызов ([ 2, 1, 0, 3, 0, 4 ]) использует элемент calldata [31] в качестве callInfo. Он делает DELEGATECALL по другому адресу 0xf4863028b093fdac9cf7fd67c0df6866ac3c7a60 с сигнатурой функции 0x0fd72adb. Закодированное значение callLength равно 7, поэтому следующие 7 элементов из calldata используются в качестве входных данных вместе с сигнатурой функции

Эти 3 вызова представляют собой 3 цикла нашего ассемблерного кода. Первые 2 вызова обрабатывают ветвь EtherDelta, а третий выполняет секцию Uniswap

Два контракта, с которыми взаимодействуют вызовы 0xd380f7e1dc7408aa007744ed3af390f8a47f9b75 и 0xf4863028b093fdac9cf7fd67c0df6866ac3c7a60, являются не верефнутыми контрактами

Это означает, что нам нужно будет декомпилировать их байт-код и выполнить отладку с помощью Foundry на уровне опкодов, чтобы понять, что происходит

Но это в следующий раз!)

Надеюсь статья была интересной и понятной!

Мой телеграмм канал - https://t.me/ortomich_crypto