April 23, 2022

EVM для задротов

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

Сегодня речь пойдет про более углубленное познание EVM, что это вообще такое и как оно действительно работает

По своей сути данная статья является переводом и выжимкой вот этого парня ТЫК (но только первой части его трилогии, в будущем возможно и остальные переведу и разберу)

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

Начнем!

Solidity → Bytecode → Opcode

Именно такой заголовок будет у данной главы

Начнем с того, что Solidity КОД НЕ ЧИТАЕМ ДЛЯ EVM! Для простоты объяснения скажу так, solidity - высокоуровневый язык, а EVM такого рода штуки читать не умеет, ему как и любой вычислительной машине нужны на вход только циферки. Именно поэтому мы и переводим весь наш solidity код в байткод, чтобы EVM могла его понять. Байткод создается вместе с контрактом, то есть в момент деплоя, вместе с ABI, но об этом сегодня не будем (ABI - интерфейс при помощи которого две программы могут между собой контактировать, это если кратко)

Вот нашел интересную картинку про деплой контракта на EVM

Ну вот, получили мы с вами большой набор цифр под названием "байткод", а что с ним дальше делать? Тут и включается наше третье звено - Opcode

Что он из себя представляет? Опкод - это набор команд, условно если стоит набор цифр "111", то надо делать сложение, если стоит "000", то надо делать вычитание. Всего в solidity 140 опкодов, и если вы скажете что их мало, и команд и операция я могу в solidity написать больше, то знайте, многие операции - комбинации этих опкодов. Полный список всех опкодов с хорошим описанием можете смотреть тут - ТЫК

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

Так вот, у каждого опкода есть свои входные данные. Это значит что если EVM видит команду, в который по условию следующие 8 цифр - входные данные, то он и воспримет их исключительно как входные данные

Я уверен вам это тяжело понять, поэтому перейдем к примеру

У нас есть вот такой простенький контракт

Вот байткод этого контракта:

608060405234801561001057600080fd5b50600436106100365760003560e01c80632e64cec11461003b5780636057361d14610059575b600080fd5b610043610075565b60405161005091906100d9565b60405180910390f35b610073600480360381019061006e919061009d565b61007e565b005b60008054905090565b8060008190555050565b60008135905061009781610103565b92915050565b6000602082840312156100b3576100b26100fe565b5b60006100c184828501610088565b91505092915050565b6100d3816100f4565b82525050565b60006020820190506100ee60008301846100ca565b92915050565b6000819050919050565b600080fd5b61010c816100f4565b811461011757600080fd5b5056fea2646970667358221220404e37f487a89a932dca5e77faaf6ca2de3b991f93d230604b1b8daaef64766264736f6c63430008070033

Тут мы находим вот такой код:

60003560e01c80632e64cec11461003b5780636057361d1461005957

Это код выборщика выполняемой функции (у нас в контракте 2 функции используется и он как раз выбирает одну из них, которую мы его попросили)

А вот так выглядит расшифровка этого байткода в опокод:

60 00                       =   PUSH1 0x00 
35                          =   CALLDATALOAD
60 e0                       =   PUSH1 0xe0
1c                          =   SHR
80                          =   DUP1  
63 2e64cec1                 =   PUSH4 0x2e64cec1
14                          =   EQ
61 003b                     =   PUSH2 0x003b
57                          =   JUMPI
80                          =   DUP1 
63 6057361d                 =   PUSH4 0x6057361d     
14                          =   EQ
61 0059                     =   PUSH2 0x0059
57                          =   JUMPI  

Повторюсь, все опкоды можно найти и посмотреть тут ТЫК

Вызов функций контрактов и Calldata

Тут мы вызываем функцию и передаем в нее аргумент "10", abi.encodeWithSignature() используется чтобы получить данные в нужном формате, а Emit на всякий случай для тестировки

На выходе мы получаем это:

0x6057361d000000000000000000000000000000000000000000000000000000000000000a

Это полученные значения из abi.encodeWithSignature("store(uint256)", 10);

store(uint256) - функция из контракта который мы использовали в самом начале

Давайте расшифруем этот байткод!

keccak256(“store(uint256)”) →  first 4 bytes = 6057361d

Как мы можем заметить, если шифрануть функицю через кечак, то мы получим первые 4 байта (6057361d), которые как раз таки и указывают на саму функцию, а после этого у нас идет 32 байта входных данных (000000000000000000000000000000000000000000000000000000000000000a)

Для тех кто не знает почему а = 10, то посмотрите перевод из десятичной системы счисления в шестнадцатеричную

Opcode и Calldata

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

А сейчас мы подробно разберем как происходит выбор функции на выше приведенных примерах!

Мы пробежимся по каждой из команд опкода (который мы получали выше) и постараемся разобраться как она работает и что делает

Мы начнем с PUSH1, которая сообщает EVM, что необходимо пропушить 1 байт, 0x00 (0 десятичная дробь), в колстак (вот этот квадратик справа). Почем мы это делаем - объясним дальше

Дальше у нас CALLDATALOAD который выталкивает первое значение в стеке (0) как input

Этот опкод загружает данные вызова в стек, используя «input» в качестве смещения. Элементы стека имеют размер 32 байта, но наши данные вызова имеют размер 36 байтов. Введенное значение — msg.data[i:i+32], где «i» — это input. Это гарантирует, что в стек помещаются только 32 байта, но позволяет нам получить доступ к любой части данных вызова

В этом случае у нас нет смещения (значение, извлеченное из стека, равно 0 из предыдущего PUSH1), поэтому мы помещаем первые 32 байта calldata в стек вызовов

Помните, ранее мы регистрировали данные о наших вызовах с помощью Emit, равного:

“0x6057361d000000000000000000000000000000000000000000000000000000000000000a”

Это означает, что последние 4 байта («0000000a») потеряны. Если бы мы хотели получить доступ к переменной uint256, мы бы использовали смещение 4, чтобы опустить сигнатуру функции, но включить всю переменную

Другой PUSH1 на этот раз с шестнадцатеричным значением 0xe0, которое имеет десятичное значение 224. Помните, что сигнатуры функций ("6057361d") имеют длину 4 байта или 32 бита. Наша загруженная calldata имеют длину 32 байта или 256 бит. 256 - 32 = 224 вы можете видеть, к чему это идет

Затем у нас есть SHR, который является правым битовым смещением. Он берет первый элемент из стека (224) в качестве входных данных о том, на сколько нужно сдвинуть, а второй элемент из стека (0x6057361d0…0a) представляет то, что нужно сдвинуть. Мы можем видеть, что после этой операции у нас остается наш 4-байтовый селектор функций в стеке вызовов

Далее идет DUP1, простой код операции, который берет значение на вершине стека и дублирует его

PUSH4 помещает 4-байтовую сигнатуру функции retrieve() (0x2e64cec1) в стек вызовов

keccak256(“retrieve()”) → first 4 bytes = 2e64cec1
Это вторая функция в нашем контракте

Если вам интересно, как он узнает это значение, помните, что оно находится в байт-коде, который был скомпилирован из кода Solidity. Таким образом, компилятор имел информацию обо всех именах функций и типах аргументов

EQ извлекает 2 значения из стека, в данном случае 0x2e64cec1 и 0x6057361d, и проверяет, равны ли они. Если да, то в стек помещает 1, если нет 0

PUSH2 помещает 2 байта данных в стек вызовов 0x003b в шестнадцатеричном формате, что равно 59 в десятичном формате

В стеке вызовов есть нечто, называемое программным счетчиком, который указывает, где в байт-коде находится следующая команда выполнения. Здесь мы устанавливаем 59, потому что это место для начала байт-кода retrieve()

Вы можете просмотреть расположение счетчика программ аналогично расположению номера строки в вашем коде Solidity. Если функция определена в строке 59, вы можете использовать номер строки, чтобы сообщить машине, где найти код для этой функции.

JUMPI означает «прыгать, если». Он извлекает 2 значения из стека в качестве входных данных, первое (59) — это место перехода (куда он перейдет), а второе (0) — логическое значение, указывающее, следует ли выполнять этот переход. Где 1 = истина и 0 = ложь

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

DUP1 снова

PUSH4 помещает 4-байтовую сигнатуру функции store(uint256) (0x6057361d) в стек вызовов.

EQ вызывается снова, однако на этот раз результат верный, поскольку сигнатуры функций совпадают

PUSH2, передает программе-счетсчику местоположение байт-кода store(uint256), 0x0059 в шестнадцатеричном формате, что равно 89 в десятичном формате.

JUMPI, на этот раз логическая проверка верна, что означает выполнение прыжка. Это обновит счетчик программ до 89, что переместит выполнение в другую часть байткода

В этом месте будет код операции JUMPDEST, без этого кода операции в пункте назначения JUMPI завершится ошибкой

Вот и все, после выполнения этого кода операции вы попадете в расположение байткода хранилища (uint156), и выполнение функции продолжится в обычном режиме

Хотя у этого контракта было только 2 функции, те же принципы применяются к контракту с более чем 20 функциями

Теперь вы знаете, как EVM определяет расположение байткода функции, которую необходимо выполнить, на основе вызова функции контракта. На самом деле это просто набор «условных выражений» для каждой функции в вашем контракте вместе с местами их перехода

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

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