В последнее время все чаще в новостях можно услышать слова "криптовалюта" и "блокчейн" и, как следствие, наблюдается приток большого количества заинтересованных этими технологиями людей, а вместе с этим и огромное количество новых продуктов. Зачастую, для реализации какой-то внутренней логики проекта или же для сбора средств используются "умные контракты" — особые программы, созданные на платформе Ethereum и живущие внутри его блокчейна. В сети уже существует достаточно материала, посвященного созданию простых смарт-контрактов и базовым принципам, однако практически нету описания работы виртуальной машины Ethereum (далее EVM) на более низком уровне, поэтому в этой серии статей я бы хотел разобрать работу EVM более детально.
Solidity — язык, созданный для разработки умных контрактов, существует относительно недавно — его разработка началась только в 2014 году и, как следствие, местами он ''сыроват''. В этой статье я начну с более общего описания работы EVM и некоторых отличительных особенностей solidity, которые нужны для понимая более низко-уровневой работы.
P.s Статья предпологает наличие некоторых базовых знаний о написании смарт-контрактов, а также о блокчейне Ethereum'a в целом, так что если вы слышите об этом в первый раз, то рекомендую сначала ознакомиться с основами, например, здесь:
- Hello world на solidity и деплой контракта в сеть
- Подборка инструментов для разработки
- Описание работы Ethereum и его блокчейна
Table of contents
- Memory
- Storage
- Memory
- Stack
- Data location of complex types
- Transactions and message calls
- Visibility
- Links
Memory types
Перед тем, как начать погружаться в тонкости работы EVM, следует понять один из самых выжных моментов — где и как хранятся все данные. Это очень важно, т.к области памяти в EVM сильно отличаются своим устройством, и, как следствие, разнится не только стоимость чтения/записи данных, но и механизмы работы с ними.
Storage
Первый и самый дорогой тип памяти — это Storage. Каждый контракт обладает своей собственной storage памятью, где хранятся все глобальные переменные (state variables), состояние которых постоянно между вызовами функций. Её можно сравнить с жестким диском — после завершения выполнения текущего кода все запишется в блокчейн, и при следующем вызове контракта у нас будет доступ ко всем полученным ранее данным.
contract Test {
// this variable is stored in storage
uint some_data; // has default value for uint type (0)
function set(uint arg1) {
some_data = arg1; // some_data value was changed and saved in global
}
}
Структурно storage представляет из себя хранилище типа ключ-значение, где все ячейки имеют размер в 32 байта, что сильно напоминает хэш-таблицы, поэтому эта память сильно разрежена и мы не получим никакого преимущества от сохранения данных в двух соседних ячейках: хранение одной переменной в 1ой чейке и другой в 1000ой ячейке будет стоить столько же газа, как если бы мы хранили их в ячейках 1 и 2.
[32 bytes][32 bytes][32 bytes]...
Как я уже говорил, этот тип памяти является самым дорогим — занять новую ячейку в storage стоит 20000 газа, изменить занятую — 5000 и прочесть — 200. Почему это так дорого? Причина проста — данные, сохранненные в storage контракта, будут записаны в блокчейн и останутся там навсегда.
Также, совсем несложно подсчитать максимальный объем информации, который можно сохранить в контракте: количество ячеек — 2^256, размер каждой — 32 байта, таким образом имеем 2^261 байт! По сути имеем некую машину Тьюринга — возможность рекурсивного вызова/прыжков и практически бесконечная память. Более чем достаточно, чтобы симулировать внутри еще один Ethereum, который будет симулировать Ethereum :)
Memory
Вторым типом памяти является Memory. Она намного дешевле чем storage, очищается между внешними (о типах функций можете прочитать в следующих главах) вызовами функций и используется для хранения временных данных: например аргументов, передаваемых в функции, локальных перемеменных и хранения значений return. Её можно сравнить с RAM — когда компьютер (в нашем случае EVM) выключается, ее содержимое стирается.
contract Test {
...
function (uint a, uint b) returns (uint) {
// a and b are stored in memory
uint c = a + b
// c has been written to memory too
return c
}
}
По внутреннему устройству memory представляет из себя байт-массив. Сначала она имеет нулевой размер, но может быть расширена 32-байтовыми порциями. В отличие от storage, memory непрерывна и поэтому хорошо упакована — намного дешевле хранить массив длины 2, хранящий 2 переменные, чем массив длины 1000, хранящий те же 2 переменные на концах и нули в середине.
Чтение и запись одного машинного слова (напомню, в EVM — это 256 бит) стоит всего 3 газа, а вот расширение памяти увеличивает свою стоимость в зависимости от текущего размера. Хранение некольких KB будет стоить недорого, однако уже 1 MB обойдется в миллионы газа, т.к цена растет квадратично.
// fee for expanding memory to SZ
TOTALFEE(SZ) = SZ * 3 + floor(SZ**2 / 512)
// if we need to expand memory from x to y, it would be
// TOTALFEE(y) - TOTALFEE(x)
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 также не создает копии.
contract C {
uint[] x; // the data location of x is storage
// the data location of memoryArray is memory
function f(uint[] memoryArray) {
x = memoryArray; // works, copies the whole array to storage
// var is just a shortcut, that allows us automatically detect a type
// you can replace it with uint[]
var y = x; // works, assigns a pointer, data location of y is storage
y[7]; // fine, returns the 8th element of x
y.length = 2; // fine, modifies x through y
delete x; // fine, clears the array, also modifies y
uint[3] memory tmpArr = [1, 2, 3]; // tmpArr is located in memory
var z = tmpArr; // works, assigns a pointer, data location of z is memory
// The following does not work; it would need to create a new temporary /
// unnamed array in storage, but storage is "statically" allocated:
y = memoryArray;
// This does not work either, since it would "reset" the pointer, but there
// is no sensible location it could point to.
delete y;
g(x); // calls g, handing over a reference to x
h(x); // calls h and creates an independent, temporary copy of x in memory
h(tmpArr) // calls h, handing over a reference to tmpArr
}
function g(uint[] storage storageArray) internal {}
function h(uint[] memoryArray) internal {}
}
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, за исключением того, что они не видны в наследуемых контрактах.
Для наглядности рассмотрим небольшой пример.
contract C {
uint private data;
function f(uint a) private returns(uint b) { return a + 1; }
function setData(uint a) { data = a; } // default to public
function getData() public returns(uint) { return data; }
function compute(uint a, uint b) internal returns (uint) { return a+b; }
}
contract D {
uint local;
function readData() {
C c = new C();
uint local = c.f(7); // error: member "f" is not visible
c.setData(3);
local = c.getData();
local = c.compute(3, 5); // error: member "compute" is not visible
}
}
contract E is C {
function g() {
C c = new C();
uint val = compute(3, 5); // acces to internal member (from derivated to parent contract)
uint tmp = f(8); // error: member "f" is not visible in derived contracts
}
}
Одним из самых частых вопросов является 'зачем нужны external функции, если всегда можно использовать public'. На самом деле, не существует случая, когда external нельзя заменить на public, однако, как я уже писал, в некоторых случаях это более эффективно. Давайте рассмотрим на конкретном примере.
contract Test {
function test(uint[3] a) public returns (uint) {
// a is copied to memory
return a[2]*2;
}
function test2(uint[3] a) external returns (uint) {
// a is located in calldata
return a[2]*2;
}
}
Выполнение public функции стоит 413 газа, в то время как вызов external версии только 281. Происходит это потому, что в public функции происходит копирование массива в memory, тогда как в external функции чтение идет напрямую из calldata. Выделение памяти, очевидно, дороже, чем чтение из calldata.
Причина того, что public функциям нужно копировать все аргументы в memory в том, что они могут быть вызваны еще и изнутри контракта, что представляет из себя абсолютно другой процесс — как я уже писал ранее, они работают посредством прыжков в коде, а массивы передаются через указатели на memory. Таким образом, когда компилятор генерирует код для internal функции, он ожидает увидеть аргументы в memory.
Для external функций, компилятору не нужно предоставлять внутренний доступ, поэтому он предоставляет доступ к чтению данных прямо из calldata, минуя шаг копирования в память.
Таким образом грамотный выбор типа 'видимости' служит не только для ограничения доступа к функциям, но и позволяет использовать их эффективнее.
P.S.: В следующих статьях я перейду к разбору работы и оптимизации сложных типов на уровне байт-кода, а также напишу об основных уязвимостях и багах, присутствующих в solidity на данный момент.
Links
Автор: Алиев Магомед