Привет! Меня зовут Руслан, я разработчик ноды Waves Enterprise. Одна из особенностей нашей платформы — это использование контейнеризированных смарт-контрактов, что позволяет писать их на любом языке и таким образом легко входить в блокчейн-разработку со знакомым стеком. В этом посте я расскажу, как у нас запускаются и валидируются смарт-контракты, как они работают и взаимодействуют с нодой. И что, наверно, интересней всего — как организовано их согласованное параллельное исполнение.
Все начинается с того, что мы создаем смарт-контракт на удобном языке в виде обычного приложения. Далее на основе приложения формируем докер-образ и загружаем в registry. Можно использовать как публичный докер-хаб, так и собственный приватный реестр. Во втором случае будет необходимо в конфигурации нод указать соответствующие авторизационные данные.
Запуск и валидация смарт-контрактов
Перед тем как контракт станет доступным для исполнения, его необходимо зарегистрировать в сети. Для этого применяется специализированная транзакция – CreateContractTransaction с идентификатором 103. В ее описании содержится ссылка на образ контракта в Docker Registry, а также хеш образа — эти данные закрепляются в блокчейне и используются в дальнейшем для валидации.
После обработки этой транзакции нода скачивает образ по указанной в поле “image” ссылке, сверяет хеш и запускает приложение контракта в виде Docker-контейнера. Таким образом, после того как транзакция 103 успешно смайнилась, контракт нельзя подменить, так как это изменит хеш образа и валидация завершится с ошибкой.
Запущенному приложению смарт-контракта необходимо авторизоваться при подключении к ноде. Нода заранее генерирует ключевую пару RSA и на ее основе при запуске контейнера формирует и передает авторизационный токен в виде JSON Web Token. Приложение смарт-контракта получает авторизационный токен из переменных окружения, инициализированных нодой при запуске контейнера. Помимо токена, через ENV передается ряд дополнительных параметров, например адрес, по которому приложение должно подключиться к ноде.
После запуска контейнера нода ожидает присоединения по gRPC API. Приложение смарт-контракта должно подключиться к ноде, используя ранее полученный авторизационный токен, и вызвать gRPC-метод connect. В результате успешного вызова нода открывает в сторону смарт-контракта стрим. По этому стриму в смарт-контракт на исполнение уже будут приходить транзакции вызова (CallContractTransaction). По сути смарт-контракт становится внешним сервисом, задача которого сводится к обработке потока входящих транзакций.
Исполнение смарт-контрактов
CallContractTransaction — специальная транзакция с идентификатором 104, которая инициирует исполнение смарт-контракта. Внутри нее содержится идентификатор контракта и параметры для его вызова. По идентификатору контракта в этой транзакции нода определяет нужный Docker-контейнер. При этом он запускается, если не был запущен ранее. Через описанный ранее gRPC-стрим нода передает CallContractTransaction в контейнер, после чего ожидает результат исполнения в течение заданного времени.
Вместе с CallContractTransaction контракт в контейнере получает обновленный авторизационный токен. Время жизни токена ограничено, как и время контракта на выполнение транзакции. Таким образом, каждая операция обработки CallContractTransaction является авторизованной.
Для обработки транзакции смарт-контракту необходимы данные, которые он может получить у ноды. За каждым смарт-контрактом в хранилище ноды закреплено соответствующее ему key-value пространство. Смарт-контракт может обращаться как к собственному пространству ключей, так и к стейту другого контракта — так происходит взаимодействие с другими контрактами.
В gRPC API предусмотрены методы, которые может вызвать контракт. Вот некоторые из них:
получение данных из key-value пространства определенного смарт-контракта;
получение данных о транзакциях, отправленных в блокчейн;
получение ролей permission системы для определенных адресов сети;
получение данных и балансов, закрепленных за определенными адресами в сети;
информации о группах для обмена конфиденциальными данными и работы с ними;
утилитарные методы, например, получение текущего времени ноды.
Результаты исполнения смарт-контрактов
По итогам исполнения транзакции смарт-контракт должен вызвать один из двух gRPC методов — CommitExecutionSuccess или CommitExecutionError. В случае успеха контракт передает новые key-value значения из своего пространства, которые впоследствии нода записывает в хранилище. В случае ошибки можно передать ноде сообщение с деталями, указать идентификатор транзакции, при обработке которой возникла ошибка, а также характер ошибки – fatal или recoverable. После обработки вызова нодой в блокчейн будет добавлена ExecutedContractTransaction (105).
Весь процесс обработки смарт-контрактов проиллюстрирован на схеме ниже:
Поясню используемые транзакции:
103 — регистрация смарт-контракта. Подписывается разработчиком смарт-контракта (с ролью contract_developer). Здесь настраивают политику валидации, адрес образа и его хеш, требуемую версию Сontract API, параметры для инициализации смарт-контракта;
104 — вызов смарт-контракта на исполнение. Подписывается инициатором исполнения смарт-контракта. Здесь указывается идентификатор, версия и входные параметры контракта;
105 — результат исполнения транзакции 103 или 104, который записывается в блокчейн. Подписывается нодой-майнером.
Для обеспечения дополнительного контроля целостности смарт-контракта наша платформа поддерживает три политики валидации. Владелец смарт-контракта может выбрать подходящую в зависимости от требований к безопасности, производительности и отказоустойчивости.
Any — транзакцию валидирует сам майнер. Политика обеспечивает максимальную производительность, но не подходит, например, для экономических расчетов.
Majority – транзакция считается валидной, если она подтверждена ⅔ валидаторов (адресов блокчейн-сети с ролью contract_validator). Политика требует дополнительных ресурсов на голосование и обеспечивает консенсус среди большинства валидаторов.
MajorityWithOneOf – наиболее жесткая политика, которая в дополнение к предыдущему пункту требует голос хотя бы одной доверенной ноды. Адреса доверенных нод устанавливает сам создатель контракта. Осторожней с отказоустойчивостью: если все доверенные ноды из списка окажутся недоступны, то такой контракт не будет выполнен.
Далее я расскажу, как обрабатываются возможные ошибки при попытке исполнить смарт-контракт.
Обработка ошибок и использование circuit breaker
Ошибки при работе со смарт-контрактами делятся на фатальные и нефатальные (recoverable). Проблемы могут произойти как внутри самого контракта, так и на интеграционных стыках, например, при взаимодействии с docker engine или даже в компоненте самой ноды.
Если контракт отлавливает ошибку во время исполнения, то вместо вызова CommitExecutionSucces он должен вызвать CommitExecutionError и передать детали ошибки и ее классификацию ноде. При фатальном исходе соответствующая транзакция удаляется из UTX — пула неподтвержденных транзакций, которые нода берет на майнинг и включает в блоки. Это исключает трату ресурсов на повторные попытки; подразумевается, что они не приведут к решению проблемы. Вот некоторые ошибки, которые нода считает фатальными:
отсутствие указанного образа контракта в registry;
несоответствие фактического хеша образа с тем, что есть в блокчейне;
неожиданная или явно классифицированная как фатальная ошибка от самого контракта;
превышение лимита попыток повторного исполнения при нефатальных ошибках.
Примеры нефатальных ошибок:
разрыв соединения со смарт контрактом;
выход за пределы допустимого времени выполнения операции смарт-контрактом;
недоступность docker-демона;
классифицированная как нефатальная ошибка от самого контракта.
Для обработки нефатальных ошибок в платформе Waves Enterprise применяется шаблон проектирования circuit breaker. В контексте модуля смарт-контрактов это прослойка между исполнителем контрактов в ноде и докер-контейнером. Circuit breaker предотвращает влияние проблем с исполнением одной транзакции на другие транзакции.
Как видно из схемы, Circuit Breaker «проксирует» все операции по контрактам, работу с контейнерами и docker хостом.
Circuit breaker включает в себя счетчик ошибок и конечный автомат, который может находиться в состоянии Closed, Open или HalfOpen. При создании circuit breaker находится в статусе Closed, счетчик ошибок равен нулю. Ошибки увеличивают счетчик на 1, успешная операция сбрасывает значение счетчика до нуля. Если значение счетчика достигает определенного значения, circuit breaker переходит в статус Open.
В статусе Open все поступающие к контейнеру операции отклоняются и нода не тратит ресурсы зря. Через заданное время circuit breaker производит попытку закрытия — переходит в статус HalfOpen, в котором следующей операции дается одна попытка для успешного выполнения.
Если эта операция проходит успешно, circuit breaker возвращается в статус Closed и счетчик ошибок обнуляется. Временная проблема решилась. Если нет, circuit breaker переходит обратно в статус Open на заданное время, помноженное на специальный множитель, и цикл повторяется.
Основные параметры circuit breaker:
max-failures — лимит ошибок после которого circuit breaker переходит в состояние Open;
contract-opening-limit — максимальное количество переходов в состояние Open. После превышения лимита формируется фатальная ошибка и из UTX удаляются все транзакции этого смарт-контракта.
reset-timeout — максимальное время ожидания circuit breaker в статусе Open, после которого произойдет попытка закрытия;
exponential-backoff-factor — множитель reset-timeout при повторном возникновении ошибки;
max-reset-timeout — максимально допустимое значение reset-timeout.
Параллельное исполнение смарт-контрактов и обеспечение согласованности данных
Требования к пропускной способности ноды постоянно растут, поэтому нам пришлось задуматься над параллельным исполнением смарт-контрактов. Многим разработчикам известны проблемы, которые появляются при внедрении параллельных вычислений. Возникает необходимость обеспечить консистентность хранимых данных и корректность результатов параллельных вычислений. Иначе возможны феномены, недопустимых для большинства бизнес-кейсов:
dirty read — транзакция читает данные, записанные параллельной, еще не зафиксированной транзакцией;
non-repeatable read — транзакция повторно считывает данные и обнаруживает, что они были изменены другой транзакцией (которая завершилась с момента предыдущего чтения);
phantom read – транзакция повторно выполняет запрос, возвращающий несколько элементов данных, удовлетворяющих условию поиска, и обнаруживает, что результат запроса изменился из-за другой транзакции;
lost update – при одновременном изменении одного key-value разными транзакциями теряются все изменения, кроме последнего.
Когда мы только внедрили параллельное исполнение, система не согласовывала доступ к данным, поэтому ее применение ограничивалось специфичными бизнес-кейсами: когда, например, смарт-контракт читал неизменяемые значения, а в результате исполнения записывал только новые значения в свое хранилище.
Чтобы избежать проблем параллельного доступа, мы рассмотрели несколько известных подходов: использование блокировок, внедрение планировщика, строящего граф исполнения, а также MVCC – Multi-version Concurrency Control). По нескольким причинам выбрали последний:
Механизм позволял не вносить существенных изменений в Contract API и сохранить обратную совместимость;
Оптимистичный подход (к которому относится MVCC) более эффективен в кейсах с небольшой вероятностью конфликтов. Большинство наших бизнес-кейсов относились к таким;
Оценка трудозатрат на внедрение механизма была минимальна по сравнению с другими вариантами.
MVCC стремится решить проблему контроля конкуренции, сохраняя несколько копий каждого элемента данных. Транзакция во время исполнения видит снимок состояния хранилища в определенный момент времени. Любые изменения, сделанные в рамках текущей транзакции, не будут видны другими транзакциями до тех пор, пока изменения не будут зафиксированы.
При обновлении элементов данных они не будут перезаписываться новыми. Вместо этого создастся их более новая версия. Версия, которую видит каждая транзакция, определяется состоянием до начала ее выполнения.
Транзакция может быть успешно зафиксирована, если она бесконфликтна. Транзакция является бесконфликтной только тогда, когда между моментом ее начала и временем фиксации, в ключ, прочитанный этой транзакцией, не выполняли записи другие транзакции. Если здесь обнаруживается конфликт, то полученные результаты отбрасываются как некорректные, и транзакция отправляется на повторное исполнение. Если конфликта нет, происходит успешная фиксация изменений.
В следующих постах мы расскажем подробней о недавно вышедшем SDK смарт-контрактов для платформы Waves Enterprise.