Для примера рассмотрим следующий код контракта:
if!self.storage[calldataload(0)]:
self.storage[calldataload(0)] = calldataload(32)
Заметим, что в реальности контракт пишется на низкоуровневом EVM-коде, но этот пример для ясности написан на Serpent, одном из наших высокоуровневых языков, который можно скомпилировать до EVM-кода. Предположим, что до транзакции хранилище нашего контракт-аккаунта было пустым, а затем посылается транзакция с 10 единицами эфира, 2000 газа, 0,001 GASPRICE и 64 байтами данных, где байты 0–31 представляют число 2, а байты 32–63 – строчку CHARLIE[114]. Здесь функция изменения состояния сработает так.
1. Убедиться, что транзакция валидна и корректно оформлена.
2. Убедиться, что у отправителя есть как минимум 2000 × 0,001 = 2 единицы эфира. После успешной проверки вычесть 2 эфира из баланса аккаунта отправителя.
3. При 2000 газа, если предположить, что транзакция занимает 170 байт и комиссия за байт равна 5, вычтем 850, и останется 1150 единиц газа.
4. Вычесть еще 10 единиц эфира из баланса аккаунта отправителя и добавить их к балансу получателя.
5. Запустить выполнение кода. В нашем примере это совсем просто: код проверяет, есть ли в хранилище контракт-аккаунта информация по адресу 2, замечает, что ее там нет, и прописывает туда значение CHARLIE. Предположим, это стоит 187 единиц газа, и газа остается 1150 – 187 = 963.
6. Добавить 963 × 0,001 = 0,963 единиц эфира в аккаунт отправителя и выдать получившееся состояние.
Если на принимающей стороне транзакции нет контракта, комиссия будет равняться GASPRICE, умноженной на длину транзакции в байтах, и никаких данных вместе с транзакцией отправлено не будет.
Отметим, что с точки зрения возврата сообщения работают эквивалентно транзакциям: если при выполнении сообщения заканчивается газ, то происходит откат к моменту вызова этого сообщения в контракте, но не к моменту начала всего контракта. Это означает, что один контракт может «безопасно» вызвать другой: если контракт A вызовет контракт B с g газа, то выполнение A гарантированно приведет к потере не более g газа. Наконец, обратим внимание, что существует операционный код CREATE, который создает контракт; его механика выполнения в целом аналогична CALL, за исключением того, что результат выполнения определяет код вновь созданного контракта.
ВЫПОЛНЕНИЕ КОДА
Код, понимаемый контрактами Ethereum, пишется на низкоуровневом байткод-языке на основе стека, который мы назвали «код виртуальной машины Ethereum», или EVM-код. Код состоит из последовательности байтов, и каждый байт представляет какую-либо операцию. В целом выполнение кода представляет собой бесконечный цикл, состоящий из повторного выполнения операции текущего счетчика программ (начиная с 0) и последующего его увеличения на 1. Процесс может закончиться в случае достижения конца кода, ошибки в коде или обнаружения в коде инструкции STOP/RETURN. Есть три типа мест, где операции могут хранить данные:
◊ стек, контейнер типа last-in-first-out, куда можно записывать и откуда можно извлекать значения;
◊ память, байтовый массив произвольного размера;
◊ долговременное хранилище контракта, информация в котором хранится парами «ключ – значение». В отличие от стека и памяти, которые очищаются после выполнения кода, в хранилище параметры могут находиться долго.
Кроме того, код имеет доступ к сумме, отправителю и информации входящего сообщения, к информации в заголовке блока, а также может вернуть байтовый массив данных на выходе.
Принцип выполнения EVM-кода на удивление прост. Во время вычисления все текущее состояние представляет собой кортеж (block_state, transaction, message, code, memory, stack, pc, gas), где block_state – полное состояние, содержащее информацию о балансах и состояниях хранилищ всех аккаунтов. В начале каждого раунда выполнения берется pc-байткод (или 0, если pc >= len(code)), выполняется инструкция, соответствующая этому байту, и каждая из инструкций по-своему действует на кортеж.
Например, ADD достает два элемента из стека и возвращает туда их сумму, после чего уменьшает gas на 1 и увеличивает pc на 1; а SSTORE берет верхние два элемента стека и помещает второй элемент в хранилище контракта под адресом, равным первому элементу. Хотя есть много способов оптимизировать выполнение EVM посредством динамической компиляции, простейшая реализация Ethereum может занять всего пару сотен строк кода.