MEV 1.1
Сегодня мы начнем погружаться в MEV и попытаемся понять что это такое и почему оно на слуху
Перевод данной статьи - ТЫК
Начнем!
На дворе апрель 2021 года, серчеры MEV бродят по сети в поисках удачи, повсюду снайперы, PvP-сражения накаляются
Новый участник вот-вот вступит в бой
На блоке 12342861 из тени появился Серчер. Он пришел ни с чем и сделал свой первый выстрел
Это история о том, как MevAlphaLeak вышел на арену MEV
Глазами Серчера
Изучение этой истории и последующее глубокое погружение дает нам возможность увидеть мир глазами Серчера в тот момент времени
Наша задача такая же, как у механика, который разбирает машину, чтобы посмотреть, как она работает. Выполняя обратный инжиниринг составных частей, механик может начать понимать мыслительный процесс инженера/проектировщика
Это гораздо более ценно, чем простое копирование стратегии. По мере того, как вы собираете эти точки зрения серчеров, вы можете строить и внедрять инновации на основе идей, которые пришли к вам
Что случилось?
Мы начнем с общего обзора того, что произошло в блоке 12342861, а затем подробно рассмотрим конкретную часть контракта
Первые транзакции MevAlphaLeak расположены в верхней части блока 12342861, давайте начнем расследование с него
- В этом разделе показаны первые 2 транзакции из блока 12342861 на Etherscan. Первый создает контракт ApeBot, а второй взаимодействует с этим контрактом. Мы видим, что mevalalphaleak.eth — это EOA, инициировавший транзакции. Обратите внимание, что mevalphaleak.eth — это совершенно новый адрес с 0 ETH в аккаунте
- Смотрим созданный контракт ApeBot на Etherscan. Адрес контракта — 0x666f80a198412bCb987c430831B57AD61facB666. Обратите внимание, что исходный код контракта был верифнут, поэтому мы можем видеть код Solidity. Обычно серчеры не верифают свои контракты, чтобы скрыть происходящее
- Вторая транзакция в блоке взаимодействует с контрактом ApeBot. Обратите внимание, что в разделе «interact with (to)» в качестве точки входа указан адрес контракта ApeBot 0x666f80a198412bCb987c430831B57AD61facB666
- Глядя на переданные токены, мы получаем приблизительное представление о том, что произошло. Флэшлоан был взят у dYdX, а затем использован в арбитраже между EtherDelta 2 и Uniswap V2 с токеном CST. Мы видим, что флэшлоан возвращается в конце
- В конце транзакции есть два интересных перевода. Первый в «Spark Pool» (0x5A0b54D5dc17e0AadC383d2db43B0a0D3E029c4c), большой Ethereum Mining Entity стоимостью ~ 0,4497 ETH. Второй — 0x90102a92e8E40561f88be66611E5437FEb339e79, что соответствует ENS mevalphaleak.eth за ~0,0004 ETH. Вы скоро узнаете актуальность этих транзакций
- Цена газа транзакции равна нулю (транзакция создания контракта также имела нулевую цену газа). В этот момент вы должны задаться вопросом, почему любой майнер принимает транзакцию с нулевым газом
Причина, по которой майнер принимает эти транзакции, связана с переводом ~ 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, поэтому номера строк должны совпадать с картинками
- Начнем с вызова wfjizxua(uint256,uint256[]). Если вам интересно, почему такое странное имя, то попробуйте хешировать его, чтобы получить сигнатуру функции. Он возвращает сигнатуру 0x00000000, которая более эффективна в отношении газа, чем сигнатура функции, которая не равна нулю (нулевой байт в calldata стоит 4 газа, тогда как ненулевой байт стоит 16 газа). Мы передаем в функцию actionFlags и actionData
- Эти данные (actionFlags и actionData), в свою очередь, передаются в ape(uint256,uint256[]). Это функция без ограничений, которая позволяет пользователю выполнять любую произвольную логику. Это означает, что у вас может быть один контракт и таргетинг на различные DEX на основе вашей calldata, а не создание индивидуальных контрактов для каждого из них. Вы увидите, как эта функция обобщается далее в статье
- Переменная «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
2) Сигнатура функции - кодируется в байтах 6 - 9, сигнатура функции для последующего delegatecall / call
- 0x78789120, мы можем найти его в Ethereum Signature Database. Он ничего не возвращает, это указывает на то, что связанный контракт может быть не верефнутым
3) callContract — закодированный в байтах 13 - 32 адрес контракта для последующего delegatecall / call
- 0xd380f7e1dc7408aa007744ed3af390f8a47f9b75, этот адрес мы можем посмотреть на Etherscan. Контракт не верефнут, поэтому нам придется работать с байткодом контракта
4) Вызов delegatecall / call - кодируется в байте 1, должен ли последующий вызов быть DELEGATECALL или CALL
5) Gas Amount for Call - закодировано в байтах 10 - 12, сколько газа выделено для последующего delegatecall / call
6) Должны ли возвращенные данные использоваться в следующем вызове - закодировано в байте 3, нужны ли возвращенные данные для следующего цикла
Чтобы укрепить наше понимание того, как используются эти закодированные значения, мы быстро пробежимся по логике, которая их использует и определяет путь выполнения
- Этот раздел кода проверяет ненулевую сигнатуру функции. Обратите внимание, как отличаются строки 226 и 234. Оба обновляют callLength, но когда сигнатура функции не равна нулю, к callLength добавляются дополнительные 4. Это для 4-байтовой сигнатуры функции. То же самое относится к строкам 228 и 236, обе предназначены для циклов for, которые зацикливаются на данных, но цикл for в строке 236 начинается с 4, а цикл в 228 начинается с 0. Опять же, это необходимо для учета 4-байтовой сигнатуры функции
- В этом разделе рассматривается callLength. Когда вы делаете DELEGATECALL или CALL, вы должны указать, где в памяти находятся calldata/input для этого вызова. Этот цикл for сохраняет элементы calldata в памяти в ожидании этого вызова. Он загружает количество элементов из calldata, указанных в закодированных данных. Если вы не уверены в том, что знаете как работает опкод DELEGATECALL, то взгляните на "EVM для задротов 5" в которой содержится подробный обзор
- В этом разделе рассматривается байт DELEGATECALL/CALL, если байт равен 0x10, то он выполняет delegatecall на «callContract», а для всего остального выполняется call
- Тут просматривается байт 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 в качестве входных данных
- Обратите внимание, что первый элемент после 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, соответствуют тому, что находится в трассировке
- Адрес «To» = 0xd380f7e1dc7408aa007744ed3af390f8a47f9b75
- CallType = delegatecall
- Газ = 132724 (выделенный газ в десятичном формате)
- Входные данные соответствуют 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 на уровне опкодов, чтобы понять, что происходит