Смарт-контракты на Solidity

Как мы уже говорили в [intro_chapter], в Ethereum существует два различных типа счетов: счета, принадлежащие внешним пользователям (EOA), и счета контрактов. EOA контролируются пользователями, часто с помощью программного обеспечения, например, приложения-кошелька, которое является внешним по отношению к платформе Ethereum. В отличие от них, счета контрактов контролируются программным кодом (также часто называемым "смарт-контрактами"), который выполняется виртуальной машиной Ethereum. Одним словом, EOA - это простые счета без какого-либо связанного кода или хранилища данных, в то время как счета контрактов имеют и связанный код, и хранилище данных. EOA контролируются транзакциями, созданными и криптографически подписанными закрытым ключом в "реальном мире", внешнем по отношению к протоколу и независимом от него, в то время как счета контрактов не имеют закрытых ключей и поэтому "контролируют себя" заранее определенным образом, предписанным кодом их смарт-контракта. Оба типа счетов идентифицируются адресом Ethereum. В этой главе мы рассмотрим контрактные счета и программный код, который ими управляет.

Что такое смарт-контракт?

Термин "умный контракт" на протяжении многих лет использовался для описания самых разных вещей. В 1990-х годах криптограф Ник Сабо придумал этот термин и определил его как "набор обещаний, указанных в цифровой форме, включая протоколы, в рамках которых стороны выполняют другие обещания". С тех пор концепция смарт-контрактов развивалась, особенно после появления децентрализованных блокчейн-платформ с изобретением Bitcoin в 2009 году. В контексте Ethereum этот термин на самом деле является немного неправильным, учитывая, что смарт-контракты Ethereum не являются ни смарт-контрактами, ни юридическими контрактами, но термин прижился. В этой книге мы используем термин "умные контракты" для обозначения неизменяемых компьютерных программ, которые детерминированно выполняются в контексте виртуальной машины Ethereum как части сетевого протокола Ethereum - то есть на децентрализованном мировом компьютере Ethereum.

Давайте распакуем это определение:

Компьютерные программы

Умные контракты - это просто компьютерные программы. Слово "контракт" не имеет юридического значения в данном контексте.

Неизменный

После развертывания код смарт-контракта не может быть изменен. В отличие от традиционного программного обеспечения, единственный способ изменить смарт-контракт - это развернуть новый экземпляр.

Детерминированный

Результат выполнения смарт-контракта одинаков для всех, кто его запускает, с учетом контекста транзакции, инициировавшей его выполнение, и состояния блокчейна Ethereum в момент выполнения.

Контекст EVM

Умные контракты работают с очень ограниченным контекстом исполнения. Они могут получить доступ к собственному состоянию, контексту транзакции, которая их вызвала, и некоторой информации о последних блоках.

Децентрализованный всемирный компьютер

EVM работает как локальный экземпляр на каждом узле Ethereum, но поскольку все экземпляры EVM работают с одним и тем же начальным состоянием и создают одно и то же конечное состояние, система в целом работает как единый "мировой компьютер".

Жизненный цикл смарт-контракта

Умные контракты обычно пишутся на языке высокого уровня, таком как Solidity. Но для запуска их необходимо скомпилировать в низкоуровневый байткод, который выполняется в EVM. После компиляции они развертываются на платформе Ethereum с помощью специальной транзакции по созданию контракта, которая идентифицируется как таковая путем отправки на специальный адрес создания контракта, а именно 0x0 (см. [contract_reg]). Каждый контракт идентифицируется Ethereum-адресом, который формируется из транзакции создания контракта как функция счета-источника и nonce. Ethereum-адрес контракта может быть использован в транзакции в качестве получателя, отправляя средства на контракт или вызывая одну из функций контракта. Обратите внимание, что, в отличие от EOA, не существует ключей, связанных с аккаунтом, созданным для нового смарт-контракта. Как создатель контракта, вы не получаете никаких особых привилегий на уровне протокола (хотя вы можете явно прописать их в смарт-контракте). Вы, конечно, не получаете закрытый ключ для учетной записи контракта, которого на самом деле не существует - мы можем сказать, что учетные записи смарт-контрактов владеют сами собой.

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

Транзакции атомарны, они либо успешно завершаются, либо отменяются. Успешное завершение транзакции означает разные вещи при разных сценариях: (1) если транзакция отправляется из EOA в другой EOA, то любые изменения в глобальном состоянии (например, остатки на счетах), сделанные транзакцией, записываются; (2) если транзакция отправляется из EOA в контракт, который не вызывает никаких других контрактов, то любые изменения в глобальном состоянии записываются (например.например, остатки на счетах, переменные состояния контрактов) (3) если транзакция отправляется от EOA к контракту, который вызывает другие контракты только таким образом, что распространяет ошибки, то любые изменения глобального состояния записываются (например, остатки на счетах, переменные состояния контрактов).например, остатки на счетах, переменные состояния контрактов); и (4) если транзакция отправляется из EOA в контракт, который вызывает другие контракты способом, который не распространяет ошибки, то могут быть записаны только некоторые изменения глобального состояния (например, остатки на счетах, переменные состояния не ошибающихся контрактов), тогда как другие изменения глобального состояния не записываются (например, переменные состояния ошибающихся контрактов). В противном случае, если транзакция отменяется, все ее последствия (изменения в состоянии) "откатываются", как если бы транзакция никогда не выполнялась. Неудавшаяся транзакция все равно записывается как попытка, и эфир, потраченный на газ для ее выполнения, списывается со счета создателя, но в остальном она не имеет никаких других эффектов на состояние контракта или счета.

Как уже упоминалось ранее, важно помнить, что код контракта не может быть изменен. Однако контракт можно "удалить", удалив код и его внутреннее состояние (хранилище) из его адреса, оставив пустой счет. Любые транзакции, отправленные на этот адрес счета после удаления контракта, не приводят к выполнению кода, поскольку там больше нет кода для выполнения. Чтобы удалить контракт, вы выполняете операционный код EVM под названием SELFDESTRUCT (ранее называвшийся SUICIDE). Эта операция стоит "отрицательный газ", возврат газа, тем самым стимулируя освобождение ресурсов клиента сети от удаления сохраненного состояния. Удаление контракта таким образом не приводит к удалению истории транзакций (прошлого) контракта, поскольку сам блокчейн неизменяем. Важно также отметить, что возможность SELFDESTRUCT будет доступна только в том случае, если автор контракта запрограммировал смарт-контракт на такую функциональность. Если в коде контракта отсутствует опкод SELFDESTRUCT или он недоступен, смарт-контракт не может быть удален.

Введение в языки высокого уровня Ethereum

EVM - это виртуальная машина, которая выполняет специальную форму кода, называемую EVM bytecode, аналогично процессору вашего компьютера, который выполняет машинный код, такой как x86_64. Мы рассмотрим работу и язык EVM гораздо более подробно в [evm_chapter]. В этом разделе мы рассмотрим, как пишутся смарт-контракты для запуска на EVM.

Хотя можно программировать смарт-контракты непосредственно в байткоде, байткод EVM довольно громоздкий и очень сложный для чтения и понимания программистами. Вместо этого большинство разработчиков Ethereum используют язык высокого уровня для написания программ и компилятор для их преобразования в байткод.

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

В целом, языки программирования можно разделить на две парадигмы: декларативную и императивную, также известные как функциональная и процедурная, соответственно. В декларативном программировании мы пишем функции, которые выражают логику программы, но не ее ход. Декларативное программирование используется для создания программ, в которых отсутствуют побочные эффекты, что означает отсутствие изменений состояния за пределами функции. К языкам декларативного программирования относятся Haskell и SQL. Императивное программирование, напротив, заключается в том, что программист пишет набор процедур, которые объединяют логику и поток программы. К императивным языкам программирования относятся C++ и Java. Некоторые языки являются "гибридными", то есть они поощряют декларативное программирование, но также могут быть использованы для выражения императивной парадигмы программирования. К таким гибридам относятся Lisp, JavaScript и Python. В целом, любой императивный язык можно использовать для написания в декларативной парадигме, но это часто приводит к неэлегантному коду. Для сравнения, чисто декларативные языки не могут быть использованы для написания в императивной парадигме. В чисто декларативных языках нет "переменных".

Хотя императивное программирование чаще всего используется программистами, бывает очень трудно писать программы, которые выполняются именно так, как ожидается. Способность любой части программы изменять состояние любой другой части затрудняет рассуждения о выполнении программы и создает множество возможностей для ошибок. Декларативное программирование, для сравнения, позволяет легче понять, как поведет себя программа: поскольку оно не имеет побочных эффектов, любую часть программы можно понять изолированно.

В смарт-контрактах ошибки буквально стоят денег. Поэтому критически важно составлять смарт-контракты без непредвиденных последствий. Для этого вы должны быть в состоянии четко сформулировать ожидаемое поведение программы. Таким образом, декларативные языки играют гораздо большую роль в смарт-контрактах, чем в программах общего назначения. Тем не менее, как вы увидите, наиболее широко используемый язык для смарт-контрактов (Solidity) является императивным. Программисты, как и большинство людей, сопротивляются изменениям!

В настоящее время поддерживаются следующие языки программирования высокого уровня для смарт-контрактов (в порядке возрастания):

LLL

Функциональный (декларативный) язык программирования с синтаксисом, похожим на Lisp. Это был первый язык высокого уровня для смарт-контрактов Ethereum, но сегодня он используется редко.

Serpent

Процедурный (императивный) язык программирования с синтаксисом, похожим на Python. Может также использоваться для написания функционального (декларативного) кода, хотя он не полностью свободен от побочных эффектов.

Solidity

Процедурный (императивный) язык программирования с синтаксисом, похожим на JavaScript, C++ или Java. Самый популярный и часто используемый язык для смарт-контрактов Ethereum.

Vyper

Недавно разработанный язык, похожий на Serpent и снова с Python-подобным синтаксисом. Предназначен для приближения к чисто функциональному Python-подобному языку, чем Serpent, но не для замены Serpent.

Bamboo

Новый язык, разработанный под влиянием Erlang, с явными переходами состояний и без итеративных потоков (циклов). Предназначен для уменьшения побочных эффектов и повышения проверяемости. Очень новый и еще не получил широкого распространения.

Как видите, существует множество языков, из которых можно выбирать. Однако из всех них Solidity, безусловно, является самым популярным, вплоть до того, что де-факто является языком высокого уровня Ethereum и даже других EVM-подобных блокчейнов. Мы проведем большую часть нашего времени, используя Solidity, но также изучим некоторые примеры на других языках высокого уровня, чтобы получить представление об их различных философиях.

Создание смарт-контракта с помощью Solidity

Solidity был создан доктором Гэвином Вудом (соавтором этой книги) как язык, специально предназначенный для написания смарт-контрактов с функциями, непосредственно поддерживающими выполнение в децентрализованной среде мирового компьютера Ethereum. Полученные в результате атрибуты являются достаточно общими, поэтому в итоге он стал использоваться для кодирования смарт-контрактов на нескольких других платформах блокчейн. Он был разработан Кристианом Рейтивесснером, а затем также Алексом Берегзасци, Лианой Хусикян, Йоичи Хираи и несколькими бывшими участниками ядра Ethereum. Сейчас Solidity разрабатывается и поддерживается как независимый проект на GitHub.

Основным "продуктом" проекта Solidity является компилятор Solidity, solc, который преобразует программы, написанные на языке Solidity, в байткод EVM. Проект также управляет важным стандартом бинарного интерфейса приложений (ABI) для смарт-контрактов Ethereum, который мы подробно рассмотрим в этой главе. Каждая версия компилятора Solidity соответствует и компилирует определенную версию языка Solidity.

Для начала работы мы загрузим двоичный исполняемый файл компилятора Solidity. Затем мы разработаем и скомпилируем простой контракт, следуя примеру, с которого мы начали в [intro_chapter].

Выбор версии Solidity

Solidity использует модель версионности, называемую семантической версионностью, которая определяет номера версий в виде трех чисел, разделенных точками: MAJOR.MINOR.PATCH. Номер "major" увеличивается для основных и обратно несовместимых изменений, номер "minor" увеличивается по мере добавления обратно совместимых функций между основными выпусками, а номер "patch" увеличивается для обратно совместимых исправлений.

На момент написания статьи Solidity находится на версии 0.6.4. Правила для основной версии 0, которая предназначена для начальной разработки проекта, другие: все может измениться в любой момент. На практике Solidity рассматривает номер "minor" как основную версию, а номер "patch" - как основную версию. Поэтому в версии 0.6.4 6 считается основной версией, а 4 - минорной.

Выход основной версии Solidity 0.5 ожидается в ближайшее время.

Как вы видели в [intro_chapter], ваши программы Solidity могут содержать директиву pragma, которая определяет минимальную и максимальную версии Solidity, с которыми они совместимы, и может быть использована для компиляции вашего контракта.

Поскольку Solidity быстро развивается, часто лучше установить последнюю версию.

Скачать и установить

Существует несколько способов загрузки и установки Solidity, либо в виде бинарного релиза, либо путем компиляции из исходного кода. Подробные инструкции можно найти в документации по Solidity.

Вот как установить последний бинарный релиз Solidity на операционную систему Ubuntu/Debian, используя менеджер пакетов apt:

$ sudo add-apt-repository ppa:ethereum/ethereum
$ sudo apt update
$ sudo apt install solc

После установки solc проверьте версию, выполнив команду:

$ solc --version
solc, the solidity compiler commandline interface
Version: 0.6.4+commit.1dca32f3.Linux.g++

Существует несколько других способов установки Solidity, в зависимости от вашей операционной системы и требований, включая компиляцию из исходного кода напрямую. Для получения дополнительной информации см. https://github.com/ethereum/solidity.

Среда развития

Для разработки в Solidity вы можете использовать любой текстовый редактор и solc в командной строке. Однако вы можете обнаружить, что некоторые текстовые редакторы, предназначенные для разработки, такие как Emacs, Vim и Atom, предлагают дополнительные возможности, такие как подсветка синтаксиса и макросы, которые облегчают разработку Solidity.

Существуют также веб-среды разработки, такие как Remix IDE и EthFiddle.

Используйте те инструменты, которые делают вас продуктивными. В конечном счете, программы Solidity - это обычные текстовые файлы. Хотя модные редакторы и среды разработки могут облегчить работу, вам не нужно ничего больше, чем простой текстовый редактор, такой как nano (Linux/Unix), TextEdit (macOS) или даже NotePad (Windows). Просто сохраните исходный код вашей программы с расширением .sol, и он будет распознан компилятором Solidity как программа Solidity.

Написание простой программы Solidity

В [intro_chapter] мы написали нашу первую программу на Solidity. Когда мы впервые создали контракт Faucet, мы использовали IDE Remix для компиляции и развертывания контракта. В этом разделе мы вернемся к Faucet, улучшим и приукрасим его.

Наша первая попытка выглядела как Faucet.sol: Контракт Solidity, реализующий кран.

Пример 1. Faucet.sol: Контракт Solidity, реализующий смеситель link:code/Solidity/Faucet.sol[]

Компиляция с помощью компилятора Solidity (solc)

Теперь мы будем использовать компилятор Solidity в командной строке для непосредственной компиляции нашего контракта. Компилятор Solidity solc предлагает множество опций, которые вы можете увидеть, передав аргумент --help.

Мы используем аргументы --bin и --optimize в solc для создания оптимизированного двоичного файла нашего примера контракта:

$ solc --optimize --bin Faucet.sol
======= Faucet.sol:Faucet =======
Binary:
608060405234801561001057600080fd5b5060cc8061001f6000396000f3fe6080604052600436106
01f5760003560e01c80632e1a7d4d14602a576025565b36602557005b600080fd5b34801560355760
0080fd5b50605060048036036020811015604a57600080fd5b50356052565b005b67016345785d8a0
000811115606657600080fd5b604051339082156108fc029083906000818181858888f19350505050
1580156092573d6000803e3d6000fd5b505056fea26469706673582212205cf23994b22f7ba19eee5
6c77b5fb127bceec1276b6f76ca71b5f95330ce598564736f6c63430006040033

Результат, который производит solc, представляет собой шестнадцатеричный двоичный файл, который может быть отправлен в блокчейн Ethereum.

ABI контракта Ethereum

В компьютерном программном обеспечении двоичный интерфейс приложения - это интерфейс между двумя программными модулями; часто между операционной системой и пользовательскими программами. ABI определяет, как структуры данных и функции доступны в машинном коде; это не следует путать с API, который определяет этот доступ в высокоуровневом, часто человекочитаемом формате в виде исходного кода. Таким образом, ABI является основным способом кодирования и декодирования данных в машинный код и из него.

В Ethereum ABI используется для кодирования вызовов контракта для EVM и для считывания данных из транзакций. Цель ABI - определить функции в контракте, которые могут быть вызваны, и описать, как каждая функция будет принимать аргументы и возвращать результат.

ABI контракта задается в виде JSON-массива описаний функций (см. Функции) и событий (см. События). Описание функции - это объект JSON с полями type, name, inputs, outputs, constant и payable. Объект описания события имеет поля type, name, inputs и anonymous.

Мы используем компилятор Solidity командной строки solc для создания ABI для нашего примера контракта Faucet.sol:

$ solc --abi Faucet.sol
======= Faucet.sol:Faucet =======
Contract JSON ABI
[{"inputs":[{"internalType":"uint256","name":"withdraw_amount","type":"uint256"}], \
"name":"withdraw","outputs":[],"stateMutability":"nonpayable","type":"function"}, \
{"stateMutability":"payable","type":"receive"}]

Как вы можете видеть, компилятор создает массив JSON, описывающий две функции, которые определены в Faucet.sol. Этот JSON может быть использован любым приложением, которое хочет получить доступ к контракту Faucet после его развертывания. Используя ABI, такое приложение, как кошелек или браузер DApp, может создавать транзакции, вызывающие функции Faucet с правильными аргументами и типами аргументов. Например, кошелек может знать, что для вызова функции withdraw ему необходимо предоставить аргумент uint256 с именем withdraw_amount. Кошелек может попросить пользователя предоставить это значение, затем создать транзакцию, которая закодирует его и выполнит функцию withdraw.

Все, что необходимо приложению для взаимодействия с контрактом, - это ABI и адрес, по которому контракт был развернут.

Выбор компилятора Solidity и версии языка

Как мы видели в предыдущем коде, наш контракт Faucet успешно компилируется с Solidity версии 0.6.4. Но что, если бы мы использовали другую версию компилятора Solidity? Язык все еще находится в постоянном движении, и все может измениться неожиданным образом. Наш контракт довольно прост, но что если бы наша программа использовала функцию, которая была добавлена только в версии Solidity 0.6.1, а мы попытались бы скомпилировать ее в версии 0.6.0?

Для решения таких проблем Solidity предлагает директиву компилятора, известную как прагма версии, которая указывает компилятору, что программа ожидает определенную версию компилятора (и языка). Давайте рассмотрим пример:

pragma solidity ^0.6.0;

Компилятор Solidity считывает праграмму версии и выдает ошибку, если версия компилятора несовместима с праграммой версии. В данном случае наша прагма версии говорит, что эта программа может быть скомпилирована компилятором Solidity с минимальной версией 0.6.0. Однако символ ^ говорит, что мы разрешаем компиляцию с любой минорной ревизией выше 0.6.0; например, 0.6.1, но не 0.7.0 (которая является основной, а не минорной ревизией). Директивы Pragma не компилируются в байткод EVM. Они используются компилятором только для проверки совместимости.

Давайте добавим директиву pragma в наш контракт Faucet. Мы назовем новый файл Faucet2.sol, чтобы отслеживать наши изменения по мере выполнения примеров, начиная с Faucet2.sol: Добавление прагмы version к Faucet.

Пример 2. Faucet2.sol: Добавление прагмы версии в Faucet link:code/Solidity/Faucet2.sol[]

Добавление прагмы версии - это лучшая практика, поскольку она позволяет избежать проблем с несоответствием версий компилятора и языка. В этой главе мы рассмотрим другие лучшие практики и продолжим совершенствовать контракт Faucet.

Программирование с помощью Solidity

В этом разделе мы рассмотрим некоторые возможности языка Solidity. Как мы упоминали в [intro_chapter], наш первый пример контракта был очень простым и к тому же несовершенным в различных отношениях. Здесь мы будем постепенно улучшать его, одновременно изучая возможности использования Solidity. Однако это не будет полным учебником по Solidity, поскольку Solidity довольно сложна и быстро развивается. Мы рассмотрим основы и дадим вам достаточную базу, чтобы вы могли самостоятельно изучить остальное. Документацию по Solidity можно найти на сайте проекта.

Типы данных

Сначала рассмотрим некоторые из основных типов данных, предлагаемых в Solidity:

Булево (bool)

Булево значение, истина или ложь, с логическими операторами ! (не), && (и), || (или), == (равно) и != (не равно).

Integer (int, uint)

Знаковые (int) и беззнаковые (uint) целые числа, объявленные с шагом в 8 бит от int8 до uint256. Без суффикса size используются 256-битные величины, чтобы соответствовать размеру слова EVM.

Фиксированная точка (fixed, ufixed)

Числа с фиксированной точкой, объявленные с помощью (u)fixedMxN, где M - размер в битах (с шагом 8 до 256), а N - количество десятичных знаков после точки (до 18); например, ufixed32x2.

Адрес

20-байтовый адрес Ethereum. Объект адреса имеет множество полезных функций-членов, основными из которых являются balance (возвращает баланс счета) и transfer (переводит эфир на счет).

Массив байтов (фиксированный)

Массивы байтов фиксированного размера, объявленные с байт1 до байт32.

Массив байтов (динамический)

Массивы байтов переменного размера, объявленные с помощью bytes или string.

Enum

Определяемый пользователем тип для перечисления дискретных значений: enum NAME {LABEL1, LABEL 2, ...}.

Массивы

Массив любого типа, фиксированный или динамический: uint32[][5] - массив фиксированного размера из пяти динамических массивов целых беззнаковых чисел.

Структура

Определяемые пользователем контейнеры данных для группировки переменных: struct NAME {TYPE1 VARIABLE1; TYPE2 VARIABLE2; ...}.

Составление карты

Таблицы хэш-поиска для пар ключ => значение: mapping(KEY_TYPE => VALUE_TYPE) NAME. В дополнение к этим типам данных Solidity также предлагает различные литералы значений, которые могут быть использованы для вычисления различных единиц измерения:

Единицы времени

Единицы секунд, минут, часов и дней можно использовать в качестве суффиксов, преобразуя их в кратные единицы секунд.

Эфирные единицы

Единицы wei, finney, szabo и ether могут использоваться в качестве суффиксов, преобразуясь в кратные значения базовой единицы wei.

В нашем примере контракта Faucet мы использовали uint (что является псевдонимом uint256) для переменной withdraw_amount. Мы также косвенно использовали переменную address, которую мы задали с помощью msg.sender. Мы будем использовать больше этих типов данных в наших примерах в оставшейся части этой главы.

Давайте воспользуемся одним из множителей единиц, чтобы улучшить читаемость нашего примера контракта. В функции withdraw мы ограничиваем максимальную сумму снятия, выражая ее в вэях, базовой единице эфира: require(withdraw_amount <= 100000000000000000); Это не очень удобно для чтения. Мы можем улучшить наш код, используя множитель единицы измерения ether, чтобы выразить значение в ether вместо wei: require(withdraw_amount <= 0.1 ether);

Предопределенные глобальные переменные и функции

Когда контракт выполняется в EVM, он имеет доступ к небольшому набору глобальных объектов. К ним относятся объекты block, msg и tx. Кроме того, Solidity открывает ряд опкодов EVM в виде предопределенных функций. В этом разделе мы рассмотрим переменные и функции, к которым можно получить доступ из смарт-контракта в Solidity.

Контекст вызова транзакции/сообщения

Объект msg - это вызов транзакции (при создании EOA) или вызов сообщения (при создании контракта), который запустил выполнение этого контракта. Он содержит ряд полезных атрибутов:

отправитель

Мы уже использовали этот параметр. Он представляет собой адрес, который инициировал вызов этого контракта, не обязательно отправителя EOA, который отправил транзакцию. Если наш контракт был вызван непосредственно транзакцией EOA, то это адрес, который подписал транзакцию, но в противном случае это будет адрес контракта.

значение msg.value

Значение эфира, отправленного с этим вызовом (в wei). газ Количество газа, оставшегося в газовом запасе данной среды выполнения. Эта функция была устаревшей в Solidity v0.4.21 и заменена функцией gasleft.

msg.data

Полезная нагрузка данных этого вызова в нашем контракте.

msg.sig

Первые четыре байта полезной нагрузки данных, которая является селектором функций.

Примечание: Каждый раз, когда контракт вызывает другой контракт, значения всех атрибутов msg изменяются, чтобы отразить информацию о новом вызывающем. Единственным исключением из этого является функция delegatecall, которая выполняет код другого контракта/библиотеки в исходном контексте msg.

Контекст транзакции

Объект tx предоставляет средства доступа к информации, связанной с транзакцией:

tx.gasprice

Цена газа в вызывающей сделке.

tx.origin

Адрес отправителя EOA для данной транзакции. ВНИМАНИЕ: небезопасно!

Блочный контекст

Объект block содержит информацию о текущем блоке:

block.blockhash(blockNumber)

Хеш блока указанного номера блока, до 256 блоков в прошлом. Исправлена и заменена функцией blockhash в Solidity v0.4.22.

блокчейн.coinbase

Адрес получателя вознаграждения за текущий блок и вознаграждения за блок.

блок.сложность

Сложность (доказательство работы) текущего блока.

блок.лимит газа

Максимальное количество газа, которое может быть потрачено по всем транзакциям, включенным в текущий блок.

блок.номер

Номер текущего блока (высота блокчейна).

блок.метка времени

Временная метка, помещенная майнером в текущий блок (количество секунд с момента эпохи Unix).

адресный объект

Любой адрес, переданный в качестве входных данных или полученный из объекта контракта, имеет ряд атрибутов и методов:

адрес.баланс

Баланс адреса, в вэй. Например, текущий баланс контракта - address(this).balance.

address.transfer(amount)

Переводит сумму (в wei) на этот адрес, выбрасывая исключение при любой ошибке. Мы использовали эту функцию в нашем примере Faucet в качестве метода для адреса msg.sender, как msg.sender.transfer.

address.send(amount)

Аналогичен передаче, только вместо исключения возвращает false при ошибке. ПРЕДУПРЕЖДЕНИЕ: всегда проверяйте возвращаемое значение send.

address.call(payload)

Низкоуровневая функция CALL - может сконструировать произвольный вызов сообщения с полезной нагрузкой данных. Возвращает false при ошибке. ПРЕДУПРЕЖДЕНИЕ: небезопасный получатель может (случайно или злонамеренно) израсходовать весь ваш газ, в результате чего ваш контракт остановится с исключением OOG; всегда проверяйте возвращаемое значение вызова.

address.delegatecall(payload)

Низкоуровневая функция DELEGATECALL, подобная callcode(...), но с полным контекстом msg, видимым текущим контрактом. Возвращает false при ошибке. ВНИМАНИЕ: только для продвинутого использования!

Встроенные функции

Среди других функций стоит отметить следующие:

addmod, mulmod

Для сложения и умножения по модулю. Например, addmod(x,y,k) вычисляет (x + y) % k.

keccak256, sha256, sha3, ripemd160

Функции для вычисления хэшей с помощью различных стандартных алгоритмов хэширования.

ecrecover

Восстанавливает адрес, использованный для подписи сообщения, из подписи.

самоуничтожение(адрес_получателя)

Удаляет текущий контракт, отправляя все оставшиеся на счету эфиры на адрес получателя.

this

Адрес счета текущего исполняемого контракта.

Определение договора

Основным типом данных Solidity является контракт; наш пример с краном просто определяет объект контракта. Подобно любому объекту в объектно-ориентированном языке, контракт - это контейнер, включающий данные и методы. Solidity предлагает два других типа объектов, которые похожи на контракт:

интерфейс

Определение интерфейса структурировано точно так же, как контракт, за исключением того, что ни одна из функций не определена, они только объявлены. Этот тип объявления часто называют заглушкой; он сообщает вам аргументы и возвращаемые типы функций без какой-либо реализации. Интерфейс задает "форму" контракта; при наследовании каждая из функций, объявленных в интерфейсе, должна быть определена в дочерней программе.

библиотека

Библиотечный контракт - это контракт, который предназначен для развертывания только один раз и использования другими контрактами с помощью метода delegatecall (см. адрес объекта).

Функции

Внутри контракта мы определяем функции, которые могут быть вызваны транзакцией EOA или другим контрактом. В нашем примере с Faucet у нас есть две функции: withdraw и (безымянная) функция fallback. Синтаксис, который мы используем для объявления функции в Solidity, следующий:

function FunctionName([parameters]) {public|private|internal|external}
[pure|view|payable] [modifiers] [returns (return types)]

Давайте рассмотрим каждый из этих компонентов:

FunctionName

Имя функции, которое используется для вызова функции в транзакции (из EOA), из другого контракта или даже внутри одного контракта. Одна функция в каждом контракте может быть определена как функция fallback с помощью ключевого слова "fallback" или функция эфира получения, определенная с помощью ключевого слова "receive". Если она присутствует, функция получения эфира вызывается всякий раз, когда данные вызова пусты (независимо от того, получен эфир или нет). В противном случае, функция fallback вызывается, когда не названа никакая другая функция. Функция обратного вызова не может иметь аргументов или возвращать что-либо.

параметры

После имени мы указываем аргументы, которые должны быть переданы функции, с их именами и типами. В нашем примере с краном мы определили uint withdraw_amount как единственный аргумент функции withdraw. Следующий набор ключевых слов (public, private, internal, external) определяет видимость функции:

публичный

Public - это значение по умолчанию; такие функции могут вызываться другими контрактами или транзакциями EOA, или изнутри контракта. В нашем примере с краном обе функции определены как публичные.

внешний

Внешние функции подобны публичным функциям, за исключением того, что их нельзя вызывать изнутри контракта, если только они явно не снабжены ключевым словом this.

внутренний

Внутренние функции доступны только изнутри контракта - они не могут быть вызваны другим контрактом или транзакцией EOA. Они могут быть вызваны производными контрактами (теми, которые наследуют данный контракт).

частный

Частные функции подобны внутренним функциям, но не могут быть вызваны производными контрактами.

Следует помнить, что термины "внутренний" и "приватный" несколько вводят в заблуждение. Любая функция или данные внутри контракта всегда видны в публичном блокчейне, то есть любой может увидеть код или данные. Ключевые слова, описанные здесь, влияют только на то, как и когда функция может быть вызвана.

Второй набор ключевых слов (pure, constant, view, payable) влияет на поведение функции:

постоянная или вид

Функция, помеченная как view, обещает не изменять никакое состояние. Термин constant - это псевдоним для view, который будет устаревшим в будущем релизе. В настоящее время компилятор не применяет модификатор view, выдавая только предупреждение, но ожидается, что в версии 0.5 Solidity это ключевое слово станет принудительным.

чистый

Чистая функция - это функция, которая не читает и не записывает переменные в хранилище. Она может работать только с аргументами и возвращать данные, не обращаясь ни к каким хранимым данным. Чистые функции предназначены для поощрения программирования в декларативном стиле без побочных эффектов и состояния.

к оплате

Оплачиваемая функция - это функция, которая может принимать входящие платежи. Функции, не объявленные как оплачиваемые, будут отклонять входящие платежи. Есть два исключения, обусловленные дизайнерскими решениями в EVM: платежи coinbase и наследование SELFDESTRUCT будут оплачены, даже если функция отката не объявлена как оплачиваемая, но это имеет смысл, поскольку выполнение кода в любом случае не является частью этих платежей.

Как вы можете видеть в нашем примере с Faucet, у нас есть одна оплачиваемая функция (функция fallback), которая является единственной функцией, которая может принимать входящие платежи.

Конструктор контрактов и самоуничтожение

Существует специальная функция, которая используется только один раз. При создании контракта также запускается функция конструктора, если таковая существует, для инициализации состояния контракта. Конструктор запускается в той же транзакции, что и создание контракта. Функция конструктора является необязательной; вы заметили, что в нашем примере с краном она отсутствует.

Конструкторы могут быть заданы двумя способами. До версии Solidity v0.4.21 включительно, конструктор - это функция, имя которой совпадает с именем контракта, как показано здесь:

contract MEContract {
    function MEContract() {
        // This is the constructor
    }
}

Сложность этого формата заключается в том, что если имя контракта изменено, а имя функции-конструктора не изменено, то это уже не конструктор. Аналогично, если в названии контракта и/или конструктора случайно допущена опечатка, функция снова перестает быть конструктором. Это может привести к довольно неприятным, неожиданным и труднообнаруживаемым ошибкам. Представьте, например, что конструктор устанавливает владельца контракта для целей контроля. Если функция на самом деле не является конструктором из-за ошибки в именовании, то владелец не только останется неустановленным во время создания контракта, но и функция может быть развернута как постоянная и "вызываемая" часть контракта, как обычная функция, что позволит любой третьей стороне взломать контракт и стать "владельцем" после создания контракта.

Для решения потенциальных проблем, связанных с тем, что функции-конструкторы могут быть основаны на идентичном имени контракта, в Solidity v0.4.22 введено ключевое слово constructor, которое работает как функция-конструктор, но не имеет имени. Переименование контракта никак не влияет на конструктор. Кроме того, теперь легче определить, какая функция является конструктором. Это выглядит следующим образом:

pragma ^0.4.22
contract MEContract {
    constructor () {
        // This is the constructor
    }
}

Подводя итог, можно сказать, что жизненный цикл контракта начинается с транзакции создания из EOA или счета контракта. Если есть конструктор, он выполняется как часть создания контракта, чтобы инициализировать состояние контракта в процессе его создания, а затем отбрасывается.

Другой конец жизненного цикла контракта - уничтожение контракта. Контракты уничтожаются специальным опкодом EVM под названием SELFDESTRUCT. Раньше он назывался SUICIDE, но это название было упразднено из-за негативных ассоциаций, связанных с этим словом. В Solidity этот опкод представлен в виде высокоуровневой встроенной функции selfdestruct, которая принимает один аргумент: адрес для получения любого остатка эфира, оставшегося на счету контракта. Это выглядит следующим образом: selfdestruct(address recipient);

Обратите внимание, что вы должны явно добавить эту команду в ваш контракт, если хотите, чтобы он был удаляемым - это единственный способ удаления контракта, и по умолчанию она отсутствует. Таким образом, пользователи контракта, которые могут рассчитывать на то, что контракт будет существовать вечно, могут быть уверены, что контракт не может быть удален, если он не содержит опкода SELFDESTRUCT.

Добавление конструктора и самоуничтожения в наш пример со смесителем

Контракт на примере смесителя, который мы представили в [intro_chapter], не имеет ни конструктора, ни функций самоуничтожения. Это вечный контракт, который не может быть удален. Давайте изменим это, добавив конструктор и функцию самоуничтожения. Вероятно, мы хотим, чтобы функция самоуничтожения могла быть вызвана только тем EOA, который изначально создал контракт. По соглашению, это обычно хранится в адресной переменной, называемой owner. Наш конструктор устанавливает переменную owner, а функция самоуничтожения сначала проверит, что владелец вызвал ее напрямую. Во-первых, наш конструктор:

// Version of Solidity compiler this program was written for
pragma solidity ^0.6.0;

// Our first contract is a faucet!
contract Faucet {

    address owner;

    // Initialize Faucet contract: set owner
    constructor() {
        owner = msg.sender;
    }

    [...]
}

В нашем контракте теперь есть переменная типа адреса с именем "владелец". Имя "владелец" не является каким-либо особенным. Мы могли бы назвать эту адресную переменную "potato" и использовать ее точно так же. Имя owner просто делает понятным ее назначение. Далее наш конструктор, который выполняется как часть транзакции создания контракта, присваивает адрес из msg.sender переменной owner. Мы использовали msg.sender в функции withdraw для идентификации инициатора запроса на вывод средств. В конструкторе, однако, msg.sender - это адрес EOA или контракта, который инициировал создание контракта. Мы знаем, что это так, потому что это функция конструктора: она выполняется только один раз, во время создания контракта.

Теперь мы можем добавить функцию для уничтожения контракта. Нам нужно убедиться, что только владелец может запустить эту функцию, поэтому мы используем оператор require для контроля доступа. Вот как это будет выглядеть:

// Contract destructor
function destroy() public {
    require(msg.sender == owner);
    selfdestruct(owner);
}

Если кто-либо вызовет эту функцию уничтожения с адреса, отличного от адреса владельца, она завершится неудачей. Но если ее вызовет тот же адрес, который был сохранен в owner конструктором, контракт самоуничтожится и отправит весь оставшийся баланс на адрес владельца. Обратите внимание, что мы не использовали опасную функцию tx.origin для определения того, желает ли владелец уничтожить контракт - использование tx.origin позволило бы злонамеренным контрактам уничтожить ваш контракт без вашего разрешения.

Модификаторы функций

Solidity предлагает специальный тип функции, называемый модификатором функции. Вы применяете модификаторы к функциям, добавляя имя модификатора в объявление функции. Модификаторы чаще всего используются для создания условий, которые применяются ко многим функциям в рамках контракта. У нас уже есть условие контроля доступа в нашей функции destroy. Давайте создадим модификатор функции, выражающий это условие:

modifier onlyOwner {
    require(msg.sender == owner);
    _;
}

Этот модификатор функции, названный onlyOwner, устанавливает условие для любой функции, которую он модифицирует, требуя, чтобы адрес, хранящийся в качестве владельца контракта, совпадал с адресом msg.sender транзакции. Это базовый шаблон проектирования для контроля доступа, позволяющий только владельцу контракта выполнять любую функцию, имеющую модификатор onlyOwner.

Возможно, вы заметили, что в модификаторе нашей функции есть своеобразный синтаксический "заполнитель" - знак подчеркивания, за которым следует точка с запятой (_;). Это место заменяется кодом функции, которая модифицируется. По сути, модификатор "оборачивается" вокруг модифицируемой функции, помещая ее код в место, обозначенное символом подчеркивания.

Чтобы применить модификатор, добавьте его имя к объявлению функции. К функции может быть применено более одного модификатора; они применяются в той последовательности, в которой они объявлены, в виде списка, разделенного пробелами.

Давайте перепишем нашу функцию destroy, чтобы использовать модификатор onlyOwner:

function destroy() public onlyOwner {
    selfdestruct(owner);
}

Имя модификатора функции (onlyOwner) находится после ключевого слова public и говорит нам, что функция destroy модифицируется модификатором onlyOwner. По сути, вы можете прочитать это как "Только владелец может уничтожить этот контракт". На практике полученный код эквивалентен "обертыванию" кода из onlyOwner вокруг destroy.

Модификаторы функций - чрезвычайно полезный инструмент, поскольку они позволяют нам писать предварительные условия для функций и применять их последовательно, делая код более легким для чтения и, как следствие, более легким для аудита безопасности. Чаще всего они используются для контроля доступа, но они достаточно универсальны и могут применяться для множества других целей.

Внутри модификатора можно получить доступ ко всем значениям (переменным и аргументам), видимым для модифицируемой функции. В данном случае мы можем получить доступ к переменной-владельцу, которая объявлена внутри контракта. Однако обратное не верно: вы не можете получить доступ ни к одной из переменных модификатора внутри модифицированной функции.

Наследование по договору

Объект контракта Solidity поддерживает наследование, которое представляет собой механизм расширения базового контракта дополнительной функциональностью. Чтобы использовать наследование, укажите родительский контракт с помощью ключевого слова is:

contract Child is Parent {
    ...
}

При такой конструкции дочерний контракт наследует все методы, функциональность и переменные родительского. Solidity также поддерживает множественное наследование, которое может быть задано именами контрактов, разделенными запятыми, после ключевого слова is:

contract Child is Parent1, Parent2 {
    ...
}

Наследование контрактов позволяет нам писать контракты таким образом, чтобы достичь модульности, расширяемости и повторного использования. Мы начинаем с простых контрактов, реализующих наиболее общие возможности, а затем расширяем их, наследуя эти возможности в более специализированных контрактах.

В нашем контракте "Кран" мы представили конструктор и деструктор, а также контроль доступа для владельца, назначаемого при строительстве. Эти возможности являются довольно общими: они будут у многих контрактов. Мы можем определить их как общие контракты, а затем использовать наследование, чтобы распространить их на контракт Faucet.

Мы начинаем с определения базового контракта Owned, который имеет переменную-владельца, устанавливая ее в конструкторе контракта:

contract Owned {
    address owner;

    // Contract constructor: set owner
    constructor() {
        owner = msg.sender;
    }

    // Access control modifier
    modifier onlyOwner {
        require(msg.sender == owner);
        _;
    }
}

Далее мы определяем базовый контракт Mortal, который наследует Owned:

contract Mortal is Owned {
    // Contract destructor
    function destroy() public onlyOwner {
        selfdestruct(owner);
    }
}

Как вы можете видеть, контракт Mortal может использовать модификатор функции onlyOwner, определенный в Owned. Косвенно он также использует адресную переменную owner и конструктор, определенный в Owned. Наследование делает каждый контракт более простым и сфокусированным на его конкретной функциональности, позволяя нам управлять деталями модульным способом.

Теперь мы можем еще больше расширить контракт Owned, унаследовав его возможности в Faucet:

contract Faucet is Mortal {
    // Give out ether to anyone who asks
    function withdraw(uint withdraw_amount) public {
        // Limit withdrawal amount
        require(withdraw_amount <= 0.1 ether);
        // Send the amount to the address that requested it
        msg.sender.transfer(withdraw_amount);
    }
    // Accept any incoming amount
    receive () external payable {}
}

Наследуя Mortal, который, в свою очередь, наследует Owned, контракт Faucet теперь имеет функции конструктора и уничтожения, а также определенного владельца. Функциональность такая же, как и в случае, когда эти функции были в Faucet, но теперь мы можем повторно использовать эти функции в других контрактах без необходимости писать их заново. Повторное использование кода и модульность делают наш код чище, легче для чтения и аудита.

Обработка ошибок (assert, require, revert)

Вызов контракта может завершиться и вернуть ошибку. Обработка ошибок в Solidity осуществляется с помощью четырех функций: assert, require, revert и throw (теперь устаревшая).

Когда контракт завершается с ошибкой, все изменения состояния (изменения переменных, баланса и т.д.) отменяются, вплоть до цепочки вызовов контракта, если было вызвано более одного контракта. Это гарантирует, что транзакции являются атомарными, то есть они либо завершаются успешно, либо не влияют на состояние и полностью отменяются.

Функции assert и require работают одинаково, оценивая условие и прекращая выполнение с ошибкой, если условие ложно. По традиции assert используется, когда ожидается, что результат будет истинным, то есть мы используем assert для проверки внутренних условий. Для сравнения, require используется при тестировании входных данных (таких как аргументы функции или поля транзакции), устанавливая наши ожидания для этих условий.

Мы использовали require в модификаторе функции onlyOwner, чтобы проверить, что отправитель сообщения является владельцем контракта: require(msg.sender == owner);

Функция require действует как условие шлюза, предотвращая выполнение остальной части функции и выдавая ошибку, если она не выполнена.

Начиная с Solidity v0.6.0, require может также включать полезное текстовое сообщение, которое может быть использовано для отображения причины ошибки. Сообщение об ошибке записывается в журнал транзакций. Таким образом, мы можем улучшить наш код, добавив сообщение об ошибке в нашу функцию require: require(msg.sender == owner, "Только владелец контракта может вызвать эту функцию");

Функции revert и throw останавливают выполнение контракта и возвращают все изменения состояния. Функция throw устарела и будет удалена в будущих версиях Solidity; вместо нее следует использовать revert. Функция revert также может принимать в качестве единственного аргумента сообщение об ошибке, которое записывается в журнал транзакций.

Некоторые условия в контракте будут вызывать ошибки независимо от того, проверяем ли мы их явным образом. Например, в нашем контракте Faucet мы не проверяем, достаточно ли эфира для удовлетворения запроса на вывод средств. Это связано с тем, что функция перевода средств выдаст ошибку и отменит транзакцию, если баланс недостаточен для осуществления перевода: msg.sender.transfer(withdraw_amount);

Тем не менее, возможно, будет лучше выполнить явную проверку и предоставить четкое сообщение об ошибке в случае неудачи. Мы можем сделать это, добавив оператор require перед передачей:

require(this.balance >= withdraw_amount,
        "Недостаточный баланс в кране для запроса на снятие средств");
msg.sender.transfer(withdraw_amount);

Дополнительный код проверки ошибок, подобный этому, немного увеличит потребление газа, но он обеспечивает лучшее информирование об ошибках, чем если бы он был опущен. Вам нужно найти правильный баланс между потреблением газа и многословной проверкой ошибок, основываясь на предполагаемом использовании вашего контракта. В случае контракта Faucet, предназначенного для тестовой сети, мы, вероятно, сделаем выбор в пользу дополнительных отчетов, даже если это будет стоить больше газа. Возможно, в случае контракта для основной сети мы предпочтем быть экономными в использовании газа.

События

Когда транзакция завершается (успешно или нет), она выдает квитанцию транзакции, как мы увидим в [evm_chapter]. Квитанция транзакции содержит записи журнала, которые предоставляют информацию о действиях, произошедших во время выполнения транзакции. События - это объекты высокого уровня Solidity, которые используются для создания этих журналов.

События особенно полезны для легких клиентов и сервисов DApp, которые могут "следить" за определенными событиями и сообщать о них в пользовательский интерфейс или изменять состояние приложения, чтобы отразить событие в базовом контракте.

Объекты событий принимают аргументы, которые сериализуются и записываются в журналы транзакций, в блокчейн. Перед аргументом можно поставить ключевое слово indexed, чтобы значение стало частью индексированной таблицы (хэш-таблицы), по которой приложение может осуществлять поиск или фильтрацию. До сих пор мы не добавили никаких событий в нашем примере с Faucet, поэтому давайте сделаем это. Мы добавим два события, одно для регистрации любых снятий и одно для регистрации любых депозитов. Мы назовем эти события соответственно Withdrawal и Deposit. Сначала мы определим события в контракте Faucet:

contract Faucet is Mortal {
    event Withdrawal(address indexed to, uint amount);
    event Deposit(address indexed from, uint amount);

    [...]
}

Мы решили сделать адреса индексируемыми, чтобы обеспечить поиск и фильтрацию в любом пользовательском интерфейсе, созданном для доступа к нашему смесителю.

Далее мы используем ключевое слово emit для включения данных о событиях в журналы транзакций:

// Give out ether to anyone who asks
function withdraw(uint withdraw_amount) public {
    [...]
    msg.sender.transfer(withdraw_amount);
    emit Withdrawal(msg.sender, withdraw_amount);
}
// Accept any incoming amount
receive () external payable {
    emit Deposit(msg.sender, msg.value);
}

Полученный контракт Faucet.sol выглядит как Faucet8.sol: Переработанный контракт Faucet, с событиями. Пример 3. Faucet8.sol: Пересмотренный контракт Faucet, с событиями link:code/Solidity/Faucet8.sol[]

Ловля событий

Итак, мы настроили наш контракт на испускание событий. Как нам увидеть результаты транзакции и "поймать" события? Библиотека web3.js предоставляет структуру данных, которая содержит журналы транзакции. В них мы можем увидеть события, сгенерированные транзакцией.

Давайте воспользуемся truffle для запуска тестовой транзакции на пересмотренном контракте Faucet. Следуйте инструкциям в [truffle] для создания каталога проекта и компиляции кода Faucet. Исходный код можно найти в репозитории GitHub книги под code/truffle/FaucetEvents.

$ truffle develop
truffle(develop)> compile
truffle(develop)> migrate
Using network 'develop'.

Running migration: 1_initial_migration.js
  Deploying Migrations...
  ... 0xb77ceae7c3f5afb7fbe3a6c5974d352aa844f53f955ee7d707ef6f3f8e6b4e61
  Migrations: 0x8cdaf0cd259887258bc13a92c0a6da92698644c0
Saving successful migration to network...
  ... 0xd7bc86d31bee32fa3988f1c1eabce403a1b5d570340a3a9cdba53a472ee8c956
Saving artifacts...
Running migration: 2_deploy_contracts.js
  Deploying Faucet...
  ... 0xfa850d754314c3fb83f43ca1fa6ee20bc9652d891c00a2f63fd43ab5bfb0d781
  Faucet: 0x345ca3e014aaf5dca488057592ee47305d9b3e10
Saving successful migration to network...
  ... 0xf36163615f41ef7ed8f4a8f192149a0bf633fe1a2398ce001bf44c43dc7bdda0
Saving artifacts...

truffle(develop)> Faucet.deployed().then(i => {FaucetDeployed = i})
truffle(develop)> FaucetDeployed.send(web3.utils.toWei(1, "ether")).then(res => \
                  { console.log(res.logs[0].event, res.logs[0].args) })
Deposit { from: '0x627306090abab3a6e1400e9345bc60c78a8bef57',
  amount: BigNumber { s: 1, e: 18, c: [ 10000 ] } }
truffle(develop)> FaucetDeployed.withdraw(web3.utils.toWei(0.1, "ether")).then(res => \
                  { console.log(res.logs[0].event, res.logs[0].args) })
Withdrawal { to: '0x627306090abab3a6e1400e9345bc60c78a8bef57',
  amount: BigNumber { s: 1, e: 17, c: [ 1000 ] } }

После развертывания контракта с помощью функции deployed мы выполняем две транзакции. Первая транзакция - это депозит (с помощью функции send), который создает событие Deposit в журнале транзакций:

Deposit { from: '0x627306090abab3a6e1400e9345bc60c78a8bef57',
  amount: BigNumber { s: 1, e: 18, c: [ 10000 ] } }

Далее мы используем функцию withdraw для снятия средств. При этом возникает событие Withdrawal:

Withdrawal { to: '0x627306090abab3a6e1400e9345bc60c78a8bef57',
  amount: BigNumber { s: 1, e: 17, c: [ 1000 ] } }

Чтобы получить эти события, мы просмотрели массив журналов, возвращенный в результате (res) транзакций. Первая запись журнала (logs[0]) содержит имя события в logs[0].event и аргументы события в logs[0].args. Отобразив их на консоли, мы можем увидеть имя события и аргументы события. События являются очень полезным механизмом не только для внутриконтрактного взаимодействия, но и для отладки во время разработки.

Вызов других контрактов (send, call, callcode, delegatecall)

Вызов других контрактов изнутри вашего контракта - очень полезная, но потенциально опасная операция. Мы рассмотрим различные способы, которыми вы можете этого добиться, и оценим риски каждого метода. Вкратце, риски возникают из-за того, что вы можете многого не знать о контракте, который вы вызываете, или о контракте, который вызывает ваш контракт. При написании умных контрактов вы должны помнить, что, хотя в основном вы ожидаете иметь дело с EOA, ничто не помешает произвольно сложным и, возможно, вредоносным контрактам вызываться вашим кодом.

Создание нового экземпляра

Самый безопасный способ вызвать другой контракт - это создать его самому. Таким образом, вы будете уверены в его интерфейсах и поведении. Для этого вы можете просто инстанцировать его, используя ключевое слово new, как в других объектно-ориентированных языках. В Solidity ключевое слово new создаст контракт на блокчейне и вернет объект, который вы сможете использовать для ссылки на него. Допустим, вы хотите создать и вызвать контракт Faucet из другого контракта под названием Token:

contract Token is Mortal {
    Faucet _faucet;

    constructor() {
        _faucet = new Faucet();
    }
}

Этот механизм построения контракта гарантирует, что вы знаете точный тип контракта и его интерфейс. Контракт Faucet должен быть определен в области видимости Token, что можно сделать с помощью оператора import, если определение находится в другом файле:

import "Faucet.sol";

contract Token is Mortal {
    Faucet _faucet;

    constructor() {
        _faucet = new Faucet();
    }
}

Вы можете опционально указать значение передачи эфира при создании, а также передать аргументы конструктору нового контракта: импортировать "Faucet.sol";

import "Faucet.sol";

contract Token is Mortal {
    Faucet _faucet;

    constructor() {
        _faucet = (new Faucet).value(0.5 ether)();
    }
}

Затем вы также можете вызвать функции Faucet. В этом примере мы вызываем функцию уничтожения Faucet из функции уничтожения Token: импортировать "Faucet.sol";

import "Faucet.sol";

contract Token is Mortal {
    Faucet _faucet;

    constructor() {
        _faucet = (new Faucet).value(0.5 ether)();
    }

    function destroy() ownerOnly {
        _faucet.destroy();
    }
}

Обратите внимание, что хотя вы являетесь владельцем контракта Token, сам контракт Token владеет новым контрактом Faucet, поэтому только контракт Token может его уничтожить.

Обращение к существующему экземпляру

Другим способом вызова контракта является приведение адреса существующего экземпляра контракта. С помощью этого метода вы применяете известный интерфейс к существующему экземпляру. Поэтому очень важно, чтобы вы точно знали, что экземпляр, к которому вы обращаетесь, действительно имеет тип, который вы предполагаете. Давайте рассмотрим пример: импортировать "Faucet.sol";

import "Faucet.sol";

contract Token is Mortal {

    Faucet _faucet;

    constructor(address _f) {
        _faucet = Faucet(_f);
        _faucet.withdraw(0.1 ether);
    }
}

Здесь мы берем адрес, предоставленный в качестве аргумента конструктора _f, и приводим его к объекту Faucet. Это намного рискованнее, чем предыдущий механизм, потому что мы не знаем наверняка, является ли этот адрес объектом Faucet. Когда мы вызываем функцию withdraw, мы предполагаем, что она принимает те же аргументы и выполняет тот же код, что и наше объявление Faucet, но мы не можем быть уверены. Насколько нам известно, функция withdraw по этому адресу может выполнить что-то совершенно отличное от того, что мы ожидаем, даже если она названа так же. Поэтому использование адресов, переданных в качестве входных данных, и их преобразование в конкретные объекты гораздо опаснее, чем самостоятельное создание контракта.

Необработанный вызов, вызов делегата

Solidity предлагает несколько еще более "низкоуровневых" функций для вызова других контрактов. Они напрямую соответствуют одноименным опкодам EVM и позволяют нам построить вызов контракта на контракт вручную. Как таковые, они представляют собой наиболее гибкий и наиболее опасный механизм вызова других контрактов.

Вот тот же пример с использованием метода вызова:

contract Token is Mortal {
    constructor(address _faucet) {
        _faucet.call("withdraw", 0.1 ether);
    }
}

Как видите, этот тип вызова является слепым вызовом функции, что очень похоже на создание необработанной транзакции, только в контексте контракта. Это может подвергнуть ваш контракт ряду рисков безопасности, главным из которых является реентерабельность, которую мы обсудим более подробно в [reentrancy_security]. Функция вызова вернет false, если возникнет проблема, поэтому вы можете оценить возвращаемое значение для обработки ошибок:

contract Token is Mortal {
    constructor(address _faucet) {
        if !(_faucet.call("withdraw", 0.1 ether)) {
            revert("Withdrawal from faucet failed");
        }
    }
}

Другим вариантом call является delegatecall, который заменил более опасный callcode. Метод callcode скоро будет устаревшим, поэтому его не следует использовать.

Как упоминалось в объекте адреса, делегатный вызов отличается от вызова тем, что контекст msg не изменяется. Например, в то время как вызов изменяет значение msg.sender на вызывающий контракт, delegatecall сохраняет тот же msg.sender, что и в вызывающем контракте. По сути, delegatecall запускает код другого контракта в контексте выполнения текущего контракта. Чаще всего он используется для вызова кода из библиотеки. Он также позволяет использовать библиотечные функции, хранящиеся в другом месте, но чтобы этот код работал с данными хранилища вашего контракта.

Вызов делегата следует использовать с большой осторожностью. Это может привести к неожиданным последствиям, особенно если вызываемый вами контракт не был разработан как библиотека.

Давайте на примере контракта продемонстрируем различные семантики вызова, используемые call и delegatecall для вызова библиотек и контрактов. В CallExamples.sol: Пример различных семантик вызова, мы используем событие для регистрации деталей каждого вызова и видим, как контекст вызова меняется в зависимости от типа вызова.

Пример 4. CallExamples.sol: Пример различных семантик вызовов link:code/truffle/CallExamples/contracts/CallExamples.sol[]

Как видно из этого примера, наш основной контракт - caller, который вызывает библиотеку под названиемLibrary и контракт под названиемContract. И вызываемая библиотека, и контракт имеют одинаковые функции calledFunction, которые испускают событие callEvent. Событие callEvent регистрирует три части данных: msg.sender, tx.origin и this. Каждый раз, когда вызывается callFunction, она может иметь разный контекст выполнения (с разными значениями потенциально всех контекстных переменных), в зависимости от того, вызывается ли она напрямую или через delegatecall.

В caller мы сначала вызываем контракт и библиотеку напрямую, вызывая callFunction в каждой из них. Затем мы явно используем низкоуровневые функции call и delegatecall для вызова calledContract.calledFunction. Таким образом, мы можем увидеть, как ведут себя различные механизмы вызова.

Давайте запустим это в среде разработки Truffle и зафиксируем события, чтобы посмотреть, как это выглядит:

truffle(develop)> migrate
Using network 'develop'.
[...]
Saving artifacts...
truffle(develop)> (await web3.eth.getAccounts())[0]
'0x627306090abab3a6e1400e9345bc60c78a8bef57'
truffle(develop)> caller.address
'0x8f0483125fcb9aaaefa9209d8e9d7b9c8b9fb90f'
truffle(develop)> calledContract.address
'0x345ca3e014aaf5dca488057592ee47305d9b3e10'
truffle(develop)> calledLibrary.address
'0xf25186b5081ff5ce73482ad761db0eb0d25abfbf'
truffle(develop)> caller.deployed().then( i => { callerDeployed = i })

truffle(develop)> callerDeployed.make_calls(calledContract.address).then(res => \
                  { res.logs.forEach( log => { console.log(log.args) })})
{ sender: '0x8f0483125fcb9aaaefa9209d8e9d7b9c8b9fb90f',
  origin: '0x627306090abab3a6e1400e9345bc60c78a8bef57',
  from: '0x345ca3e014aaf5dca488057592ee47305d9b3e10' }
{ sender: '0x627306090abab3a6e1400e9345bc60c78a8bef57',
  origin: '0x627306090abab3a6e1400e9345bc60c78a8bef57',
  from: '0x8f0483125fcb9aaaefa9209d8e9d7b9c8b9fb90f' }
{ sender: '0x8f0483125fcb9aaaefa9209d8e9d7b9c8b9fb90f',
  origin: '0x627306090abab3a6e1400e9345bc60c78a8bef57',
  from: '0x345ca3e014aaf5dca488057592ee47305d9b3e10' }
{ sender: '0x627306090abab3a6e1400e9345bc60c78a8bef57',
  origin: '0x627306090abab3a6e1400e9345bc60c78a8bef57',
  from: '0x8f0483125fcb9aaaefa9209d8e9d7b9c8b9fb90f' }

Давайте посмотрим, что здесь произошло. Мы вызвали функцию make_calls и передали адрес callContract, а затем поймали четыре события, испускаемые каждым из различных вызовов. Давайте рассмотрим функцию make_calls и пройдемся по каждому шагу.

Первый звонок: _calledContract.calledFunction();

Здесь мы вызываем callContract.calledFunction напрямую, используя высокоуровневый ABI для calledFunction. Вызывается следующее событие:

sender: '0x8f0483125fcb9aaaefa9209d8e9d7b9c8b9fb90f',
origin: '0x627306090abab3a6e1400e9345bc60c78a8bef57',
from: '0x345ca3e014aaf5dca488057592ee47305d9b3e10'

Как вы можете видеть, msg.sender - это адрес контракта caller. tx.origin - это адрес нашего счета, web3.eth.accounts[0], который отправил транзакцию вызывающему контракту. Событие было вызвано callContract, как видно из последнего аргумента в событии. Следующее обращение в make_calls - к библиотеке:

calledLibrary.calledFunction();

Оно выглядит идентично тому, как мы вызывали контракт, но ведет себя совершенно по-другому. Давайте посмотрим на второе испускаемое событие:

sender: '0x627306090abab3a6e1400e9345bc60c78a8bef57',
origin: '0x627306090abab3a6e1400e9345bc60c78a8bef57',
from: '0x8f0483125fcb9aaaefa9209d8e9d7b9c8b9fb90f'

На этот раз msg.sender - это не адрес звонящего. Вместо этого он является адресом нашего счета и совпадает с адресом происхождения транзакции. Это происходит потому, что при вызове библиотеки вызов всегда является делегативным и выполняется в контексте вызывающей стороны. Таким образом, когда код вызываемой библиотеки выполнялся, он наследовал контекст выполнения вызывающей стороны, как если бы его код выполнялся внутри вызывающей стороны. Переменная this (показанная как from в испускаемом событии) является адресом вызывающей стороны, даже если доступ к ней осуществляется изнутри вызываемой библиотеки.

Следующие два вызова, использующие низкоуровневый вызов и вызов делегата, подтверждают наши ожидания, вызывая события, которые отражают то, что мы только что видели.

Газовые соображения

Газ, более подробно описанный в [gas], является невероятно важным фактором при программировании смарт-контрактов. Газ - это ресурс, ограничивающий максимальный объем вычислений, который Ethereum позволит потреблять транзакции. Если во время вычислений лимит газа будет превышен, произойдет следующая серия событий:

  • Возникает исключение "закончился бензин".
  • Восстанавливается (возвращается) состояние контракта до его исполнения.
  • Весь эфир, использованный для оплаты газа, берется в качестве платы за транзакцию; он не возвращается.

Поскольку газ оплачивается пользователем, инициирующим транзакцию, пользователям не рекомендуется вызывать функции, которые имеют высокую стоимость газа. Поэтому в интересах программиста минимизировать стоимость газа для функций контракта. Для этого существуют определенные практики, которые рекомендуется применять при разработке смарт-контрактов, чтобы минимизировать стоимость газа при вызове функции.

Избегайте массивов динамического размера

Любой цикл по динамически изменяемому массиву, в котором функция выполняет операции над каждым элементом или ищет определенный элемент, создает риск использования слишком большого количества газа. Действительно, контракт может исчерпать свой запас газа до того, как будет найден желаемый результат, или до того, как будет произведено действие над каждым элементом, что приведет к потере времени и эфира без получения какого-либо результата.

Избегайте обращений к другим контрактам

Вызов других контрактов, особенно если стоимость газа их функций неизвестна, создает риск того, что газ закончится. Избегайте использования библиотек, которые недостаточно хорошо протестированы и широко используются. Чем меньше библиотека была проверена другими программистами, тем выше риск ее использования.

Оценка стоимости газа

Если вам нужно оценить количество газа, необходимое для выполнения определенного метода контракта с учетом его аргументов, вы можете воспользоваться следующей процедурой:

var contract = web3.eth.contract(abi).at(address);
var gasEstimate = contract.myAweSomeMethod.estimateGas(arg1, arg2,
    {from: account});

gasEstimate сообщит вам количество единиц газа, необходимое для ее выполнения. Это оценка, потому что полнота Тьюринга в EVM - относительно тривиально создать функцию, для выполнения разных вызовов которой потребуется совершенно разное количество газа. Даже производственный код может тонко изменять пути выполнения, что приводит к огромным различиям в затратах газа от одного вызова к другому. Однако большинство функций разумны, и estimateGas в большинстве случаев дает хорошую оценку. Для получения цены на газ из сети вы можете использовать: var gasPrice = web3.eth.getGasPrice(); Отсюда можно оценить стоимость газа: var gasCostInEther = web3.utils.fromWei((gasEstimate * gasPrice), 'ether'); Давайте применим наши функции оценки стоимости газа для оценки стоимости газа в нашем примере Faucet, используя код из репозитория книги. Запустите Truffle в режиме разработки и выполните файл JavaScript gas_estimates.js: Используя функцию estimateGas, gas_estimates.js. Пример 5. gas_estimates.js: Использование функции estimateGas

var FaucetContract = artifacts.require("./Faucet.sol");

FaucetContract.web3.eth.getGasPrice(function(error, result) {
    var gasPrice = Number(result);
    console.log("Gas Price is " + gasPrice + " wei"); // "10000000000000"

    // Get the contract instance
    FaucetContract.deployed().then(function(FaucetContractInstance) {

        // Use the keyword 'estimateGas' after the function name to get the gas
        // estimation for this particular function (aprove)
        FaucetContractInstance.send(web3.utils.toWei(1, "ether"));
        return FaucetContractInstance.withdraw.estimateGas(web3.utils.toWei(0.1, "ether"));

    }).then(function(result) {
        var gas = Number(result);

        console.log("gas estimation = " + gas + " units");
        console.log("gas cost estimation = " + (gas * gasPrice) + " wei");
        console.log("gas cost estimation = " +
                FaucetContract.web3.utils.fromWei((gas * gasPrice), 'ether') + " ether");
    });
});

Вот как это выглядит в консоли разработки Truffle:

$ truffle develop

truffle(develop)> exec gas_estimates.js
Using network 'develop'.

Gas Price is 20000000000 wei
gas estimation = 31397 units
gas cost estimation = 627940000000000 wei
gas cost estimation = 0.00062794 ether

Рекомендуется оценить газовую стоимость функций в рамках рабочего процесса разработки, чтобы избежать неожиданностей при развертывании контрактов в mainnet.

Выводы

В этой главе мы начали подробно работать с умными контрактами и изучили язык программирования контрактов Solidity. Мы взяли простой пример контракта Faucet.sol и постепенно улучшали его и усложняли, используя его для изучения различных аспектов языка Solidity. В [vyper_chap] мы будем работать с Vyper, другим контрактно-ориентированным языком программирования. Мы сравним Vyper с Solidity, показав некоторые различия в дизайне этих двух языков и углубив наше понимание программирования смарт-контрактов.