Под капотом Ethereum Virtual Machine. Часть 1 — Solidity basics

в 11:35, , рубрики: blockchain, Ethereum, solidity, Программирование

В последнее время все чаще в новостях можно услышать слова "криптовалюта" и "блокчейн" и, как следствие, наблюдается приток большого количества заинтересованных этими технологиями людей, а вместе с этим и огромное количество новых продуктов. Зачастую, для реализации какой-то внутренней логики проекта или же для сбора средств используются "умные контракты" — особые программы, созданные на платформе Ethereum и живущие внутри его блокчейна. В сети уже существует достаточно материала, посвященного созданию простых смарт-контрактов и базовым принципам, однако практически нету описания работы виртуальной машины Ethereum (далее EVM) на более низком уровне, поэтому в этой серии статей я бы хотел разобрать работу EVM более детально.

Solidity — язык, созданный для разработки умных контрактов, существует относительно недавно — его разработка началась только в 2014 году и, как следствие, местами он ''сыроват''. В этой статье я начну с более общего описания работы EVM и некоторых отличительных особенностей solidity, которые нужны для понимая более низко-уровневой работы.

P.s Статья предпологает наличие некоторых базовых знаний о написании смарт-контрактов, а также о блокчейне Ethereum'a в целом, так что если вы слышите об этом в первый раз, то рекомендую сначала ознакомиться с основами, например, здесь:

Table of contents

  1. Memory
    • Storage
    • Memory
    • Stack
  2. Data location of complex types
  3. Transactions and message calls
  4. Visibility
  5. 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 :)

https://i.imgur.com/fPD96YR.jpg

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 на данный момент.

Автор: Алиев Магомед

Источник

Поделиться