EVM для задротов 4
Сегодня мы продолжим углубляться в EVM и пытаться в нем разобраться!
По своей сути данная статья является переводом и выжимкой вот этого парня ТЫК
Так же хочу предупредить, новичкам в программировании скорее всего будет сложно понять происходящее, поэтому если вы действительно хотите все впитать, то читайте неспешно и вместе с гуглом
А перед прочтением обязательно ознакомьтесь с:
Начнем!
Мы собираемся изучить архитектуру Ethereum, его структуры данных и заглянуть внутрь клиента «Go Ethereum» (Geth)
Мы начнем с данных, содержащихся в блоке Ethereum, а после перейдем к хранилищу конкретного контракта. Мы закончим рассмотрением реализации опкодов SSTORE и SLOAD в Geth
Архитектура эфира
Рассмотрим изображения ниже. Не пугайтесь, к концу этой статьи вы поймете, как именно все это работает. Это представление архитектуры Ethereum и данных, содержащихся в цепочке Ethereum
Вместо того, чтобы рассматривать диаграмму в целом, мы проанализируем ее по частям. А пока давайте сосредоточимся на «Block N Header» и содержащихся в нем полях
Block Header
Хэдер блока содержит ключевую информацию о блоке Ethereum. Ниже приведен фрагмент «Block N Header» вместе с полями данных. Взгляните на этот блок 14698834 на etherscan и посмотрите, какие поля вы там видите
Хэдер блока содержит следующие поля:
- Prev Hash — хеш Keccak родительского (прошлого) блока
- Nonce — используется для доказательства выполнения работы
- Timestamp — время записи блока по UNIX
- Uncles Hash — хэш Keccak uncle-блоков (тут подробнее о них)
- Beneficiary — адрес майнера, который смайнил блок и который соответсвенно будет вознагражден
- LogsBloom — фильтр Блума из двух полей, адреса и лога в квитанциях
- Difficulty — Скалярное значение сложности предыдущего блока
- Extra data — 32 байта доп данных, относящихся к этому блоку
- Block Num — скалярное значение количества блоков-предков
- Gas Limit — Скалярное значение текущего лимита использования газа на блок
- Gas Used — скалярное значение общего количества газа, потраченного на транзакции в этом блоке
- Mix Hash — 256-битное значение, используемое с одноразовым номером для доказательства выполнения вычислений
- State Root — Keccak хэш корневого узла дерева состояний (после выполнения)
- Transaction Root — Keccak хэш корневого узла дерева транзакций
- Receipt Root — Keccak хэш корневого узла дерева квитанций
Более подробно о всех этих полях - ТУТ
Давайте посмотрим, как эти поля соотносятся с кодовой базой клиента Geth. Мы рассмотрим структуру «Header», определенную в block.go, которая представляет заголовок блока.
Мы видим, что значения, указанные в кодовой базе, соответствуют нашей концептуальной схеме. Теперь наша цель — перейти от заголовка блока к хранилищу отдельного контракта
Для этого нам нужно сосредоточиться на поле «State root» в заголовке блока
State root
«State root» действует как дерево Меркла (тут подробнее) в том смысле, что это хэш, который зависит от всех фрагментов данных, которые лежат под ним. Если какая-либо часть данных изменится, то корень также изменится.
Структура данных под «State root» представляет собой Merkle Patricia Trie, в которой хранится пара «ключ-значение» для каждой учетной записи Ethereum в сети, где ключ — это адрес ethereum, а значение — объект учетной записи Ethereum (На самом деле ключ — это хэш адреса Ethereum, а значение — это учетная запись Ethereum, закодированная RLP, однако пока мы можем пропустить это)
Ниже приведен раздел диаграммы «Ethereum Architecture», который представляет Merkle Patricia Trie для «State root»
Merkle Patricia Trie — это нетривиальная структура данных, поэтому в этой статье мы не будем углубляться в нее (но можно дополнительно посмотреть тут). Вместо этого мы можем изучить модель mapping ключа адреса с учетной записью Ethereum
Ethereum Account
Учетная запись Ethereum — это согласованное представление адреса Ethereum. Он состоит из 4 предметов:
- Nonce - количество транзакций, совершенных аккаунтом
- Balance - баланс счета в Wei
- Code Hash — хэш байт-кода, хранящегося в контракте/аккаунте.
- Storage Root — Keccak хэш корневого узла хранилища trie (после выполнения)
Мы видим все это в данном фрагменте исходного изображения архитектуры Ethereum
Опять же, мы можем перейти к кодовой базе Geth и найти соответствующий файл state_account.go и структуру, определяющую «Ethereum account», называемую StateAccount
Мы снова видим, что значения, указанные в кодовой базе, соответствуют нашей схеме
Далее нам нужно углубиться в поле «Storage Root» в учетной записи Ethereum
Storage Root
Storage Root очень похож на State Root тем, что под ним находится еще одно Merkle Patricia trie
Разница в том, что на этот раз ключи — это слоты для хранения, а значения — это данные в каждом слоте
Опять же, на самом деле существует RLP-кодирование значений и хэширование ключей, которое происходит как часть этого процесса
Ниже приведен раздел диаграммы «Ethereum Architecture», который представляет Merkel Patricia Trie для «Storage Root»
Как и прежде, «Storage Root» — это корневой хеш, на который влияет изменение каких-либо базовых данных (контрактное хранилище)
Любое изменение в хранилище контракта повлияет на «Storage Root», который, в свою очередь, повлияет на «State Root», который, в свою очередь, повлияет на «Block Header»
На этом этапе статьи мы достигли нашей цели — перенеслись с блока эфириума на хранилище отдельного контракта!
Следующая часть статьи — это глубокое погружение в кодовую базу Geth. Мы кратко рассмотрим, как инициализируется хранилище контрактов и что происходит, когда вызываются коды операций SSTORE и SLOAD
Это поможет вам установить связи между тем, что мы обсуждали только что, и вашим кодом solidity и базовыми опкодами хранилища
StateDB → stateObject → StateAccount
Для начала нам нужен новый контракт. Совершенно новый контракт означает совершенно новый StateAccount
Прежде чем мы начнем, есть 3 структуры, с которыми мы будем взаимодействовать:
StateAccount — это представление Ethereum для «Ethereum accounts»
StateObject представляет «Ethereum account», которая модифицируется
StateDB в протоколе Ethereum используются для хранения чего-либо в дереве Меркла. Это общий интерфейс запроса для получения контрактов и Ethereum accounts
Давайте посмотрим, как эти 3 элемента взаимосвязаны и как они соотносятся с тем, что мы обсуждали
- В структуре StateDB мы видим, что в ней есть поле stateObjects, которое представляет собой mapping адресов с stateObjects (помните, что “State Root” Merkle Patricia Trie представлял собой mapping адресов Ethereum с учетными записями Ethereum, а stateObject — это учетная запись Ethereum, которая модифицируется)
- stateObject, мы видим, что в нем есть поле данных типа StateAccount (помните, что ранее в этой статье мы сопоставили учетную запись Ethereum с StateAccount в Geth)
- Структура StateAccount, мы уже видели эту структуру, она представляет учетную запись Ethereum, а поле Root представляет «Storage Root», который мы обсуждали ранее
На этом этапе некоторые части головоломки начинают собираться вместе. Теперь у нас есть контекст, чтобы увидеть, как инициализируется новый «Ethereum account» (StateAccount)
Инициализация новой учетной записи Ethereum (StateAccount)
Чтобы создать новый StateAccount, нам нужно будет взаимодействовать с файлом statedb.go и структурой StateDB
В StateDB есть функция createObject, которая создает новый объект stateObject и передает в него пустой StateAccount. Это фактически создает пустой «Ethereum account»
На приведенной ниже диаграмме показана логика кода
- В StateDB есть функция createObject, которая принимает адрес Ethereum и возвращает stateObject (помните, что stateObject представляет изменяемую учетную запись Ethereum)
- Функция createObject вызывает функцию newObject, передавая в stateDB адрес и пустой StateAccount (помните, что StateAccount = учетная запись Ethereum), она возвращает stateObject
- В возвращаемых значениях функции newObject мы видим ряд полей, связанных с stateObject, адресом, данными, dirtyStorage и т.д.
- Поле данных stateObject сопоставляется с пустым входом StateAccount в функции. Обратите внимание, что нулевые значения заменяются в StateAccount в строках 103–111
- Возвращается stateObject, который содержит инициализированный StateAccount в качестве поля данных
Итак, у нас есть пустой stateAccount, что нам делать дальше?
Мы хотим сохранить некоторые данные, и для этого нам нужно использовать код операции SSTORE
SSTORE
Прежде чем мы углубимся в реализацию SSTORE в Geth, давайте быстро напомним себе, что делает SSTORE
Он извлекает 2 значения из стека, сначала 32-байтовый ключ, затем 32-байтовое значение и сохраняет это значение в указанном слоте памяти, определяемом ключом
Ниже приведен поток кода Geth для кода операции SLOAD, давайте посмотрим, что он делает
- Начнем с файла instructions.go, который определяет все опкоды EVM. В этом файле мы находим функцию «opSstore»
- Переменная scope, которая передается в функцию, содержит данные контракта, такие как стек, память и т. д. Мы извлекаем 2 значения из стека и помечаем их в loc (сокращение от location) и в val (сокращение от value)
- Затем два значения, извлеченные из стека, используются в качестве входных данных вместе с адресом контракта для функции SetState, связанной с StateDB. Функция SetState использует адрес контракта, чтобы проверить, существует ли объект stateObject для этого контракта, и если нет, то он будет создан. Затем он вызывает SetState для этого stateObject, передавая в базу данных StateDB ключ и значение
- Для StateObject функция SetState выполняет некоторые проверки fake storage и того, изменилось ли значение, а затем запускает добавление журнала
- Если вы посмотрите на комментарий кода о структуре журнала, то увидите, что журнал используется для отслеживания изменений состояния, чтобы их можно было отменить в случае исключения выполнения или запроса на изменение
- После обновления журнала вызывается для storageObject функцию setState с ключом и значением. Это обновляет dirtyStorage в storageObject
Итак, мы обновили dirtyStorage в stateObject с ключом и значением. Что это на самом деле означает и как это связано со всем, что мы узнали до сих пор
Начнем с определения dirtyStorage в коде
- dirtyStorage определяется в структуре stateObject, имеет тип Storage и описывается как «Записи хранилища, которые были изменены в ходе выполнения текущей транзакции»
- Тип хранилища, соответствующий dirtyStorage, представляет собой простой mapping common.Hash с common.Hash
- Hash type — просто массив байтов длины HashLength
- HashLength — константа, определенная как 32
Вам должен быть знаком mapping с 32-байтовым ключом и 32-байтовым значением. Именно так мы концептуально рассматривали хранилище контрактов в "EVM для задротов 3"
Возможно, вы заметили pendingStorage и originStorage в stateObject прямо над полем dirtyStorage. Все они связаны, во время финализации dirtyStorage копируется в pendingStorage, который, в свою очередь, копируется в originStorage при обновлении дерева
После обновления дерева «Storage Root» в StateAccount, также будет обновлен во время «коммита» StateDB. Это записывает новое состояние в базу данных дерева в памяти
SLOAD
Снова давайте быстро напомним себе, что делает SLOAD
Он извлекает 1 значение из стека, 32-байтовый ключ, который представляет слот для хранения и возвращает хранящееся там 32-байтовое значение
Ниже приведен поток кода Geth для кода операции SLOAD, давайте посмотрим, что он делает
- Опять же, мы начинаем с файла instructions.go, где мы можем найти функцию «opSload». Мы берем местоположение (слот для хранения) для SLOAD с вершины стека, используя peek
- Мы вызываем функцию GetState для StateDB, передавая адрес контракта и место хранения. GetState получает объект stateObject, связанный с этим адресом контракта. Если stateObject не равен нулю, он вызывает GetState для этого stateObject
- Функция GetState для stateObject выполняет проверку fakeStorage, а затем проверяет dirtyStorage
- Если dirtyStorage существует, то вернет значение положения ключа в mapping dirtyStorage (dirtyStorage представляет самое актуальное состояние контракта, поэтому мы пытаемся сначала вернуть его)
- В противном случае вызовет функцию GetCommitedState, чтобы найти значение в дереве хранилища. Снова проверяется наличие fakeStorage
- Если pendingStorage существует, то вернет значение по местоположению ключа в pendingStorage mapping
- Если ничего из вышеперечисленного не вернулось, то перейдите в originStorage и извлечет значение оттуда
Вы заметите, что функция сначала пыталась вернуть dirtyStorage, затем pendingStorage, а только потом originStorage. Это имеет смысл, поскольку во время выполнения dirtyStorage является наиболее актуальным mapping, за которым следует pendingStorage, а затем originStorage
Одна транзакция может манипулировать одним слотом хранилища несколько раз, поэтому мы должны убедиться, что у нас есть самое последнее значение
Давайте представим, что SSTORE происходит перед SLOAD, в том же слоте и в той же транзакции. В этой ситуации dirtyStorage будет обновлен в SSTORE, а в SLOAD будет возвращен
Теперь у вас есть понимание того, как SSTORE и SLOAD реализованы на уровне клиента Geth. Как они взаимодействуют с объектами состояния и хранилища, а так же как обновление слота хранилища связано с «мировым состоянием» Ethereum
Это было интенсивно, но ты справился. Я предполагаю, что эта статья оставила у вас больше вопросов, чем было до того, как вы начали, но это часть удовольствия от криптодева)