EVM часть 2: контракт с одним полем

В предыдущей статье мы разобрали загрузку пустого смарт-контракт в блокчейн. Мы узнали, что компиляторы контрактов достаточно глупые и преобразуют команды solidiy в готовые блоки. Из-за этого контракты порой содержат лишние инструкции. Давайте попробуем оптимизировать наш контракт. Но есть одна проблема. Как мы узнаем, что после наших оптимизаций контракт работает. Ведь пустой контракт ничего не делал. Если он будет неисправен, то он тоже ничего не будет делать.

Давайте добавим в наш смарт-контракт публичное поле типа uint. Тогда если после всех оптимизаций контракт вернет нам пустой поле, то будем считать, что наши оптимизации не сломали контракт. А заодно и узнаем, как в байткоде создаются поля.

Предыдущая статья. Следующая статья.

Полный список статей по теме тут.

Заходите в наш телеграмм канал — Blockchain Witnesses! Делитесь опытом или задавайте вопросы, если что-то непонятно.

Создаем и исследуем публичное поле

Давайте возьмем наш смарт контракт и добавим туда поле:

А теперь получим байткод (можно с помощью Remix, как в предыдущей статье).

Убираем метаданные (они у нас занимают — 106 символов в конце) и получаем чистый байткод:

60806040526103095f553480156013575f80fd5b5060ac80601f5f395ff3fe6080604052348015600e575f80f
d5b50600436106026575f3560e01c80630dbe671f14602a575b5f80fd5b60306044565b604051603b91906
05f565b60405180910390f35b5f5481565b5f819050919050565b6059816049565b82525050565b5f60208
201905060705f8301846052565b9291505056fe

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

Давайте разбираться, что есть что.

Конструктор

Мы уже знакомы с этим участком кода. Это инициализация указателя на свободную память.

 А дальше идет самое интересное — запись числа 777 в блокчейн. Storage как мы знаем — это и есть память блокчйена. А команда SSTORE как раз сохраняет значение в storage.

Следующий блок мы также знаем из предыдущей статьи. Он проверяет передавался ли эфир при загрузке смарт-контракта. Если передавался, то выходим с ошибкой (ведь наш конструктор не payable). Если эфир не передавался, то прыгаем на 0x13.


Следующий участок кода нам также знаком. Он копирует эффективный код смарт-контракт. Т.е. код без конструктора и метаданные в память по адресу 0x0. Затем возвращает код с помощью RETURN. Таким образом код записывается в блокчейн.


С конструктором мы разобрались.  В нем все, что нам встречалось в предыдущей статье. Кроме блока, в котором записывается данные нашего поля a = 777 в блокчей:

Давайте изучим код самого контракта.

Сам смарт-контракт

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

Первыми идут инструкции, котроые нам уже встречались. Это стандартная проверка на передачу эфира. Если эфир был передан, то в данном случае происходит выход по REVERT’у. Т.е. как обычно. Если эфир не был передан, то прыгаем на 0x0e.


Однако, как мы видим ниже, код уже идет с 002D. А 0x0e? Место куда мы должны были прыгнуть  должно начинаться сильно раньше! Помните, мы копировали наш код в нулевой адрес, так вот — после копирования все адреса уменьшаться ровно на размер кода конструктора. Давайте поправим адреса — вычтем из них 0x24. Тогда  байткод смарт-контракта без конструктора будет выглядеть так.

Теперь посмотрим куда мы на самом деле прыгаем в 0x0e:

Этот код нам не встречался ранее. JUMPDEST понятно — так помечаются места, куда можно прыгать. Дальше POP — очистка стека. А вот дальше мы помещаем в стэк 0x04 и выполняем CALLDATASIZE. Эта инструкция помещает в стэк размер calldata из входящего сообщения. Что за входящее сообщение? Мы уже знаем из Solidity, что в функциях есть доступ к объекту msg. В котором хранится много чего полезного:

  • msg.data — calldata — данные о том, какая функция вызывается у контракта и какие аргументы ей передаются.
  • msg.sender — адрес того, кто вызывает функцию нашего контракта
  • msg.sig — первые 4 байта calldata — т.е. идентификатор функции, которая вызывается у контракта
  • msg.value — сколько wei передано при вызове контракта

Именно размер данных msg.data инструкция CALLDATASIZE помещает в стэк. 

Дальше идет инструкция LT . Расшифровывается как Less than — меньше чем.  Как можно догадаться, она сравнивает первое значение на стэке со вторым. А именно — если второе значение на стэке меньше первого , то в стэк помещается единица. Иначе в стэк помещается 0.
На стэке у нас сейчас 0x04 и msg.data.size. Если 4 > msg.data.size, то в стэк помещается единица.

Но что это значит ? 4-ка тут выбрана не случайно. В solidiy мы привыкли называть функции говорящими именами. Машине все эти имена не нужны и могут занимать много места. Поэтому в байткоде принято вместо имен указывать 4-байтовый идентификатор функции (на самом деле это 4 байта от хэша kecaak256 от объявления функции). В этом участке кода сравнивают размер msg.data.size с размером идентификатора функции. Если размер меньше 4, то это точно не вызов функции. Таким образом это сравнение определяет — является ли вызов смарт-контракта вызовом функции или нет.

Дальше понятно, если вызов смарта не является вызовом функции то переходим на 0x26. А что у нас в 0x26 ?

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

Вернемся тогда к ветке, в которой вызов нашего контракта является вызовом функции. А там видим вот что:

CALLDATALOAD — читает 32 байта начиная с адреса, который в стэке. Прочитанные данные помещает в стэк. У нас в стэке 0. Поэтому будут прочитаны 32 байта с начала msg.data.

SHR —  shift right — сдвиг в право — инструкция, которая сдвигает первый аргумент в стэке на кол-во равное второму аргументу в стэке. В нашем случае в стэке первым аргументом лежат 32 байта от msg.data. А вторым агрументом мы положили 0xe0. Зачем такой сдвиг нужен?

  • 32 байта = 32*8 = 256 бит
  • 0xe0 = 224
  • если мы сдвинем 256 бит на 224 бита вправо, то получим 256 — 224 = 32 бита. А 32 бита / 8 = 4 байта.

Давайте посмотрим на примере как это работает. Пусть наши 32 байта — это:

201905060705f8301846052565b9291505056fe0910390f35b5f5534801560d0

Сдвинув в право мы получим такое значение.

000000000000000000000000000000000000000000000000000000020190506

Т.е. 32 байта сдвигаются вправо, чтобы получить 4 байта. А 4 байта — это ничто иное как размер идентификатора функции. Т.е. этим сдвигом мы выделяем идентификатор функции.

Далее выполняется команда дублирования значения стэка DUP1. Зачем это сделано, станет ясно позже. Пока запомним, что в стэке у нас два 4-байтного идентификатора функции.

Дальше мы видим такой код

В стэк помещается 4-байтовый идентификатор. Выполняется команда сравнения EQ. Equals — равно. Если два последних значения в стэке равны, то в стэк помещается единица. Прыгаем на 0x2a, если в стэке единица. Т.е. тут происходит сравнение идентификатора функции из msg.data — т.е. функции, которая была вызвана с идентификатором функции 0x0dbe671f. Если идентификаторы совпадают, то мы идем на 0x2a.

Давайте пока оставим этот прыжок на время и посмотрим, что будет если идентификаторы не совпадут. Дальше идет участок кода:

Т.е. просто происходит выход из контракта с отменой всех изменений.

Теперь вернемся к нашему сравнению идентификатора вызываемой функции с 0x0dbe671f. Что же это такое? Давайте возьмем какой-нибудь онлайн тул для получения значения kecaak-256. Например, вот этот. И введем в него «a()». Тогда мы получим следующую картину:


Первые четыре байта полученного значения — это наши искомые 0dbe671f. Выходит, что это идентификатор функции a(). А что это за функция такая? Мы же никакие функции не писали в контракте. Давайте вспомни, как называется наше поле, которому мы присвоили 777. Правильно! Его имя a. И объявлено это поле публичным, для того, чтобы можно было его удобно прочитать.

Компилятор Soldity для публичных полей автоматически генерирует функцию для чтения. И имя этой функции совпадает с именем поля.

Вот откуда взялось это сравнение. Это сравнение определяет какая функция в контракте была вызвана. И если вызвана функция a(), то прыгаем на код этой функции. Мы его разберем чуть позже.

Помните что перед сравнением идентификатор вызванной функции дублировался на стэке?

Посмотрим внимательно на этот код. Если сравнение не проходит, то задублированное значение в стэке нигде не используется. Забегая вперед скажу — это задублированное значение не используется и при переходе на 0x2a. Так зачем это дублирование?
Как я и говорил ранее компилятор глупый. Генерирует код шаблонами. Это дублирование имело бы смыл, если бы у нас было несколько функций и несколько сравнений. А несколько сравнений — это несколько участков кода с 1с по 25. Давайте представим, что у нас три сравнения.

Каждая операция cравнения забирает со стэка два аргумента! Один из которых идентификатор вызываемой функции. Именно поэтому и нужно дублирование, что к следующему сравнению в стэке остался идентификатор вызываемой функции. Мы видим, что все три участка кода — шаблонны! Скорее всего компилятор берет шаблон и генерирует код для любого кол-ва сравнений. Хоть для одного, хоть для ста. Но мы то с Вами видим, что наш код с вызовом одной функции можно оптимизировать, убрав инструкцию DUP1. Тем самым мы сэкономим 3 единицы газа!

Код функции

А теперь давайте вернемся к нашей функции a(). Мы дошли до прыжка по адресу 0x2a. По этому адресу расположен код нашей функции. Давайте посмотрим на этот код:

Тут пока ничего интересного не происходит. В стэк помещается два значения. Очевидно будет прыжок по адресу 0x44 при этом стэке останется 0x30. Давайте посмотрим на 0x44:

Инструкция SLOAD читает 32-байтное значение по адресу 0 (который мы положили в стэк командой PUSH0) из storage и помещает в стэк. А что у нас расположено по нулевому адресу в storage? Давайте вспомним код из конструктора:

Правильно — в конструкторе мы загрузили в storgae по адресу 0 значение 777. Т.е. это есть значение нашего поля a.

Далее мы видим интересную инструкцию DUP2. Она дублирует последнее второе значение на стэке. А последнее второе значение у нас 0x30.  Помните мы перед прыжком на 0x44 помещали в стэк 0x30 — вот это оно и есть. Поэтому дальше мы прыгаем на 0x30. Когда мы перейдем на 0x30 то в стэке у нас будет значение поля a = 777.

Судя по прыжку на этот участок кода — он, вероятно, тоже какой-то шаблонный и сделан для чтения переменных в стэк. Но пока это только догадка.

Давайте посмотрим на 0x30:

Тут мы видим чтение MLOAD в стэк указателя на участок свободной памяти (мы помним, что этот указатель находится всегда по адресу 0x40 в памяти). Далее в стэк помещается 0x3b. Теперь в стэке у нас такие данные:

  • 0x3b
  • указатель на свободную память — 0x80
  • значение переменной a  = 777
  • 0x30

Далее идет не знакомые ранее нам две операции SWAP2 и SWAP1. SWAP2 — меняет местами первый и третий элементы стэка, а SWAP1 первый и второй. После выполнения этих операций стэк будет выглядеть так:

  • указатель на свободную память — 0x80
  • значение переменной a  = 777
  • 0x3b
  • 0x30

Далее мы прыгаем на 0x5f. Далее я будут приводить участки кода и состояние стэка после них. Не все из участков понятны для чего они. Многие из них скорее всего шаблонный код и имеет смысл, если у нас много функций в контракте. Но сейчас наша задача понять, что реально полезного делает функция. И что является результатом работы кода. Вы можете потренироваться и после каждого участка кода попробовать написать как будет меняться стэк.

После выполнения этого кода стэк будет выглядеть так:

  • значение переменной a  = 777
  • 0x80
  • 0x70
  • 0xa0
  • 0x80
  • значение переменной a  = 777
  • 0x3b
  • 0x30

Тут стоит пояснить, что скорее всего добавление 0x20 к 0x80 — это получение указателя на следующую свободную ячейку памяти. Мы помним, что EVM оперирует 32 байтными значениями. А 32 — это 0x20.

После выполнения этого кода стэк будет таким:

  • значение переменной a  = 777
  • 0x59
  • значение переменной a  = 777
  • 0x80
  • 0x70
  • 0xa0
  • 0x80
  • значение переменной a  = 777
  • 0x3b
  • 0x30

После выполнения этого участка кода стэк станет таким.

  • значение переменной a  = 777
  • значение переменной a  = 777
  • 0x80
  • 0x70
  • 0xa0
  • 0x80
  • значение переменной a  = 777
  • 0x3b
  • 0x30

А этот участок кода нам важен. Тут присутствует команда MSTORE. И если Вы следили за стэком. То MSTORE запишет в память по адресу 0x80 наше значение 777. А прыжок будет выполнен на адрес 0x70. После чего стэк будет выглядеть так:

  • 0xa0
  • 0x80
  • значение переменной a  = 777
  • 0x3b
  • 0x30

Прыгаем на 0x70

После выполнения этого участка кода стэк станет таким:

  • 0xa0
  • 0x30

Прыгаем на 0x3b:

Вот он — наш выход и программы. И перед RETURN у нас стэк будет выглядеть так:

  • 0x80
  • 0x20
  • 0x30

Нас интересуют первые два значения. RETURN их будет воспринимать как адрес в памяти из которого возвращаем значение и 0x20 — как размер возвращаемого значения. Т.е. возвращаются 32 байта из памяти по адресу 0x80. А что у нас по адресу 0x80 ? Несколько шагов назад мы туда из storage загрузили значение нашего поля a = 777. Вот мы и нашли эффективный код нашей функции.

Таким образом, если отбросить все пока не эффективные пока для нас блоки, то код функции делает следующее:

  1. Загружает из storage значение поля «a» в стэк
  2. Записывает значение из стэка в память по адресу указателя свободной памяти.
  3. Возвращает из памяти значение.

Но зачем же все  эти промежуточные действия? Смысл в них есть. И, порой этот смысл проявляется, когда контракт содержит несколько разных функций. Тогда имеет смысл выделять общий блоки, чтобы не дублировать их в каждой функции. Например, блоки чтения из storage. Но в нашей одной функции большинство этих действий лишние. Поэтому, мы смело можем оптимизировать наш контракт. Что мы из сделаем в следующей статье.

А пока давайте подведем итоги, что нового мы узнали.

Резюме

Мы разобрали контракт с одним полем. Посмотрели, как в конструкторе поля записываются в storage — память блокчейна.

Мы узнали, что для публичных полей компилятор solidiy создает одноименную функцию чтения.

Мы также узнали, что в контракте есть селектор функций — блок, в котором идентификатор вызываемой функции из транзакции сравнивается с идентификаторами функций в контракте.

Теперь мы знаем, что условно наш что смарт-контракт состоит из следующих блоков:

Теперь у нас все готово, чтобы попробовать оптимизировать наш смарт-контракт. Чем в следующей статье мы и займемся.

Предыдущая статья. Следующая статья.

Полный список статей по теме тут.

Заходите в наш телеграмм канал — Blockchain Witnesses! Делитесь опытом или задавайте вопросы, если что-то непонятно.

Добавить комментарий