Под капотом Ethereum Virtual Machine. Часть 1 — Solidity basics
В последнее время все чаще в новостях можно услышать слова «криптовалюта» и «блокчейн» и, как следствие, наблюдается приток большого количества заинтересованных этими технологиями людей, а вместе с этим и огромное количество новых продуктов. Зачастую, для реализации какой-то внутренней логики проекта или же для сбора средств используются «умные контракты» — особые программы, созданные на платформе Ethereum и живущие внутри его блокчейна. В сети уже существует достаточно материала, посвященного созданию простых смарт-контрактов и базовым принципам, однако практически нету описания работы виртуальной машины Ethereum (далее EVM) на более низком уровне, поэтому в этой серии статей я бы хотел разобрать работу EVM более детально.
Solidity — язык, созданный для разработки умных контрактов, существует относительно недавно — его разработка началась только в 2014 году и, как следствие, местами он »сыроват». В этой статье я начну с более общего описания работы EVM и некоторых отличительных особенностей solidity, которые нужны для понимая более низко-уровневой работы.
P.s Статья предпологает наличие некоторых базовых знаний о написании смарт-контрактов, а также о блокчейне Ethereum’a в целом, так что если вы слышите об этом в первый раз, то рекомендую сначала ознакомиться с основами, например, здесь:
Table of contents
Memory types
Перед тем, как начать погружаться в тонкости работы EVM, следует понять один из самых выжных моментов — где и как хранятся все данные. Это очень важно, т.к области памяти в EVM сильно отличаются своим устройством, и, как следствие, разнится не только стоимость чтения/записи данных, но и механизмы работы с ними.
Storage
Первый и самый дорогой тип памяти — это Storage. Каждый контракт обладает своей собственной storage памятью, где хранятся все глобальные переменные (state variables), состояние которых постоянно между вызовами функций. Её можно сравнить с жестким диском — после завершения выполнения текущего кода все запишется в блокчейн, и при следующем вызове контракта у нас будет доступ ко всем полученным ранее данным.
Структурно storage представляет из себя хранилище типа ключ-значение, где все ячейки имеют размер в 32 байта, что сильно напоминает хэш-таблицы, поэтому эта память сильно разрежена и мы не получим никакого преимущества от сохранения данных в двух соседних ячейках: хранение одной переменной в 1ой чейке и другой в 1000ой ячейке будет стоить столько же газа, как если бы мы хранили их в ячейках 1 и 2.
Как я уже говорил, этот тип памяти является самым дорогим — занять новую ячейку в storage стоит 20000 газа, изменить занятую — 5000 и прочесть — 200. Почему это так дорого? Причина проста — данные, сохранненные в storage контракта, будут записаны в блокчейн и останутся там навсегда.
Также, совсем несложно подсчитать максимальный объем информации, который можно сохранить в контракте: количество ячеек — 2^256, размер каждой — 32 байта, таким образом имеем 2^261 байт! По сути имеем некую машину Тьюринга — возможность рекурсивного вызова/прыжков и практически бесконечная память. Более чем достаточно, чтобы симулировать внутри еще один Ethereum, который будет симулировать Ethereum 🙂
Memory
Вторым типом памяти является Memory. Она намного дешевле чем storage, очищается между внешними (о типах функций можете прочитать в следующих главах) вызовами функций и используется для хранения временных данных: например аргументов, передаваемых в функции, локальных перемеменных и хранения значений return. Её можно сравнить с RAM — когда компьютер (в нашем случае EVM) выключается, ее содержимое стирается.
По внутреннему устройству memory представляет из себя байт-массив. Сначала она имеет нулевой размер, но может быть расширена 32-байтовыми порциями. В отличие от storage, memory непрерывна и поэтому хорошо упакована — намного дешевле хранить массив длины 2, хранящий 2 переменные, чем массив длины 1000, хранящий те же 2 переменные на концах и нули в середине.
Чтение и запись одного машинного слова (напомню, в EVM — это 256 бит) стоит всего 3 газа, а вот расширение памяти увеличивает свою стоимость в зависимости от текущего размера. Хранение некольких KB будет стоить недорого, однако уже 1 MB обойдется в миллионы газа, т.к цена растет квадратично.
Stack
Так как EVM имеет стэковую организацию, неудивительно, что последней областью памяти является stack — он используется для проведения всех вычислений EVM, а цена его использования аналогична memory. Он имеет максимальный размер в 1024 элемента по 256 бит, однако только верхние 16 элементов доступны для использования. Конечно, можно перемемещать элементы стэка в memory или storage, однако произвольный доступ невозможен без предварительного удаления верхушки стэка. Если стэк переполнить — выполнение контракта прервется, так что я советую оставить всю работу с ним компилятору 😉
Data location of complex types
В solidity работа со ‘сложными’ типами, такими как структуры и массивы, которые могут не уложиться в 256 бит, должна быть организована более тщательно. Так как их копирование может обходиться довольно дорого, мы должны задумываться о том, где их хранить: в memory (которая не постоянна) или же в storage (где хранятся все глобальные переменные). Для этого в solidity для массивов и структур существует дополнительный параметр — ‘местоположение данных’. В зависимости от контекста, этот параметр всегда имеет стандартное значение, однако он может быть изменен ключевыми словами storage и memory. Стандартным значением для аргументов функции является memory, для локальных переменных это storage (для простых типов это по-прежнему memory) и для глобальных переменных это всегда storage.
Существует также и третье местоположение — calldata. Данные, находящиеся там, неизменяемы, а работа с ними организована также, как в memory. Аргументы external функций всегда хранятся в calldata.
Местоположение данных также важно, потому что оно влияет на то, как работает оператор присвоения: присвоения между переменными в storage и memory всегда создают независимую копию, а вот присвоение локальной storage переменной только создаст ссылку, которая будет указывать на глобальную переменную. Присвоение типа memory — memory также не создает копии.
Transactions and message calls
В Ethereum’е существует 2 вида аккаунтов, разделяющих одно и то же адресное пространство: External accounts — обычные аккаунты, контролируемые парами приватных-публичных ключей (или проще говоря аккаунты людей) и contract accounts — аккаунты, контролируемые кодом, хранящимся вместе с ними (смарт-контракты). Транзакция представляет из себя сообщение от одного аккаунта к другому (который может быть тем же самым, или же особым нулевым аккаунтом, смотрите ниже), содержащее какие-то данные (payload) и Ether.
С транзакциями между обычным аккаунтами все понятно — они всего-лишь передают значение. Когда целевым аккаунтом является нулевой аккаунт (с адресом 0), транзакция создает новый контракт, а его адрес формирует из адреса отправителя и его количества отправленных транзакций (‘nonce’ аккаунта). Payload такой транзакции интерпретируется EVM как байткод и выполняется, а вывод сохраняется как код контракта.
Если же целевым аккаунтом является contract account, выполняется код, находящийся в нем, а payload передается как входные данные. Самостоятельно отправлять тразакции contract account’ы не могут, однако можно запускать их в ответ на полученные (как от external account’ов, так и от других contract account’ов). Таким образом можно обеспечить взаимодействие контрактов друг с другом посредством внутренних транзакций (message calls). Внутренние транзакции идентичны обычным — они также имеют отправителя, получателя, Ether, gas и тд., а контракт может установить для них gas-limit при отправке. Единственное отличие от транзакций, созданных обычными аккаунтами, заключается в том, то живут они исключительно в среде исполнения Ethereum.
Visibility
В solidity существует 4 типа ‘видимости’ функций и переменных — external, public, internal и private, стандартом является public. Для глобальных переменных стандартом является internal, а external невозможен. Итак, рассмотрим все варианты:
- External — функции этого типа являются частью интерфейса контракта, что значит они могут быть вызваны из других контрактов посредством message call. Вызванный контракт получит чистую копию memory и доступ к данным payload, которые будут расположены в отдельной секции — calldata. После завершения выполнения, возвращаемые данные будут размещены в заранее выделенном вызвавшим контрактом месте в memory. External функция не может быть вызвана изнутри контракта напрямую (то есть мы не можем использовать func() , однако все еще возможен такой вызов — this.func() ). В случае, когда на вход подается много данных, эти функции могут быть более эффективны, чем public (об этом я напишу ниже).
- Internal — функции, а также глобальные переменные этого типа могут использоваться только внутри самого контракта, а также контрактов, унаследованных от этого. В отличие от external функций, первые не используют message calls, а работают посредством ‘прыжков’ по коду (инструкция JUMP). Благодаря этому, при вызове такой функции memory не очищается, что позволяет передавать по ссылке сложные типы, хранящиеся в memory (вспомните пример из главы Data location — tmpArr передается в функцию h по ссылке).
- Public — public функции универсальны: они могут быть вызваны как внешне — то есть являются частью интерфейса контракта, так и изнутри контракта. Для public глобальных переменных автоматически генерируется специальная getter-функция — она имеет external видимость и возвращает значение переменной.
- Private — private функции и переменные ничем не отличаются от internal, за исключением того, что они не видны в наследуемых контрактах.
Для наглядности рассмотрим небольшой пример.
Одним из самых частых вопросов является ‘зачем нужны external функции, если всегда можно использовать public’. На самом деле, не существует случая, когда external нельзя заменить на public, однако, как я уже писал, в некоторых случаях это более эффективно. Давайте рассмотрим на конкретном примере.
Выполнение public функции стоит 413 газа, в то время как вызов external версии только 281. Происходит это потому, что в public функции происходит копирование массива в memory, тогда как в external функции чтение идет напрямую из calldata. Выделение памяти, очевидно, дороже, чем чтение из calldata.
Причина того, что public функциям нужно копировать все аргументы в memory в том, что они могут быть вызваны еще и изнутри контракта, что представляет из себя абсолютно другой процесс — как я уже писал ранее, они работают посредством прыжков в коде, а массивы передаются через указатели на memory. Таким образом, когда компилятор генерирует код для internal функции, он ожидает увидеть аргументы в memory.
Для external функций, компилятору не нужно предоставлять внутренний доступ, поэтому он предоставляет доступ к чтению данных прямо из calldata, минуя шаг копирования в память.
Таким образом грамотный выбор типа ‘видимости’ служит не только для ограничения доступа к функциям, но и позволяет использовать их эффективнее.
Источник
Ethereum Virtual Machine (EVM)
The EVM’s physical instantiation can’t be described in the same way that one might point to a cloud or an ocean wave, but it does exist as one single entity maintained by thousands of connected computers running an Ethereum client.
The Ethereum protocol itself exists solely for the purpose of keeping the continuous, uninterrupted, and immutable operation of this special state machine; It’s the environment in which all Ethereum accounts and smart contracts live. At any given block in the chain, Ethereum has one and only one ‘canonical’ state, and the EVM is what defines the rules for computing a new valid state from block to block.
Some basic familiarity with common terminology in computer science such as bytes, memory, and a stack are necessary to understand the EVM. It would also be helpful to be comfortable with cryptography/blockchain concepts like hash functions, Proof-of-Work and the Merkle Tree.
From Ledger to State Machine
The analogy of a ‘distributed ledger’ is often used to describe blockchains like Bitcoin, which enable a decentralized currency using fundamental tools of cryptography. A cryptocurrency behaves like a ‘normal’ currency because of the rules which govern what one can and cannot do to modify the ledger. For example, a Bitcoin address cannot spend more Bitcoin than it has previously received. These rules underpin all transactions on Bitcoin and many other blockchains.
While Ethereum has its own native cryptocurrency (Ether) that follows almost exactly the same intuitive rules, it also enables a much more powerful function: smart contracts. For this more complex feature, a more sophisticated analogy is required. Instead of a distributed ledger, Ethereum is a distributed state machine. Ethereum’s state is a large data structure which holds not only all accounts and balances, but a machine state, which can change from block to block according to a pre-defined set of rules, and which can execute arbitrary machine code. The specific rules of changing state from block to block are defined by the EVM.
Diagram adapted from Ethereum EVM illustrated
The Ethereum State Transition Function
The EVM behaves as a mathematical function would: Given an input, it produces a deterministic output. It therefore is quite helpful to more formally describe Ethereum as having a state transition function:
Given an old valid state (S) and a new set of valid transactions (T) , the Ethereum state transition function Y(S, T) produces a new valid output state S’
In the context of Ethereum, the state is an enormous data structure called a modified Merkle Patricia Trie, which keeps all accounts linked by hashes and reducible to a single root hash stored on the blockchain.
Transactions are cryptographically signed instructions from accounts. There are two types of transactions: those which result in message calls and those which result in contract creation.
Contract creation results in the creation of a new contract account containing compiled smart contract bytecode. Whenever another account makes a message call to that contract, it executes its bytecode.
The EVM executes as a stack machine with a depth of 1024 items. Each item is a 256-bit word, which was chosen for the ease of use with 256-bit cryptography (such as Keccak-256 hashes or secp256k1 signatures).
During execution, the EVM maintains a transient memory (as a word-addressed byte array), which does not persist between transactions.
Contracts, however, do contain a Merkle Patricia storage trie (as a word-addressable word array), associated with the account in question and part of the global state.
Compiled smart contract bytecode executes as a number of EVM opcodes, which perform standard stack operations like XOR , AND , ADD , SUB , etc. The EVM also implements a number of blockchain-specific stack operations, such as ADDRESS , BALANCE , KECCAK256 , BLOCKHASH , etc.
Diagrams adapted from Ethereum EVM illustrated
All implementations of the EVM must adhere to the specification described in the Ethereum Yellowpaper.
Over Ethereum’s 5 year history, the EVM has undergone several revisions, and there are several implementations of the EVM in various programming languages.
All Ethereum clients include an EVM implementation. Additionally there are multiple standalone implementations, including:
Источник