Как стать автором
Обновить

Threaded Solidity: написание смарт-контрактов для асинхронных сетей на базе Threaded Virtual Machine (TVM)

Уровень сложностиСложный
Время на прочтение24 мин
Количество просмотров2.3K

В 2020 году написание смарт-контрактов для единственной существовавшей на тот момент децентрализованной сети, использовавшей TON Virtual Machine, а именно, Telegram Open Network (TON), требовало изучение языка Fift, написанного для создания и тестирования смарт-контрактов, исполняемых на этой виртуальной машине.

Позднее команды разработчиков разделились: одна из них начала совершенствовать TON Virtual Machine, и в результате доработок появилась Threaded Virtual Machine (TVM). Далее были запущены новые сети Everscale и Venom (последняя сегодня находится на этапе тестнета), в которых для исполнения смарт-контрактов используется уже Threaded Virtual Machine.

Был написан специальный компилятор Solidity кода в машинные инструкции TVM. Естественно, это добавило удобства разработке смарт-контрактов для блокчейнов Everscale и Venom, однако из-за существенных различий между Ethereum VM и Threaded VM, API компилятора серьезно расширяет стандартный Solidity.

В этой статье мы опишем характерные особенности написания смарт-контрактов для блокчейна на Threaded Virtual Machine (TVM). Для файлов смарт-контрактов в сетях Everscale и Venom используется расширение .tsol, что означает Threaded Solidity, благодаря которому асинхронные смарт-контракты дифференцируются от обычного синхронного солидити. Тем не менее, расширение .sol тоже используется.

Инструменты разработчика для решений на TVM

Написание смарт-контрактов для TVM-блокчейнов будет значительно комфортнее с установкой комьюнити-плагинов для Jetbrains IDEA или Visual Studio Code.

Тестирование смарт-контрактов осуществляется с помощью фреймворка Locklift. Он поддерживает автоматизированные тесты на Mocha, и, кроме того, тестирование может производиться не в тест/мейннете, а внутри собственного окружения Locklift Network.

Сеть Locklift скорее подходит для тестирования различных сценариев исполнения смарт-контрактов, поскольку не сохраняет стейт, в отличие от тестов на локальной ноде.

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

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

Для дальнейшего взаимодействия со смарт-контрактом предлагается использование Everscale Inpage Provider – SDK с набором API для взаимодействия с блокчейнами Everscale и Venom. Набор инструментов можно сбилдить как библиотеку для Typescript или Javascript. Опыт написания схож с использованием фреймворка web3.js для написания смарт-контрактов на Solidity.

Особенности языка Threaded Solidity

Первое о чем стоит понимать, когда речь идет о написании смарт-контрактов для децентрализованных сетей Venom и Everscale: 

  • исполнение кода смарт-контрактов производится на Threaded Virtual Machine (TVM), которая работает с собственной структурой данных – древом ячеек;

  • сети работают в асинхронной парадигме;

  • любое действие в сети – взаимодействие между смарт-контрактами посредством внутренних сообщений;

  • с каждого смарт-контракта взимается storageFee – комиссия за хранение данных в сети. Когда баланс смарт-контракта достигает -0,1, он сначала замораживается, а затем удаляется из состояния сети. Для возможности восстановления в сети остается хеш состояния смарт-контракта.

Эти аспекты напрямую влияют на развитие Threaded Solidity и на его отличия от стандартного Solidity.

О ячейках, слайсах, билдерах в TVM

Как было упомянуто выше TVM работает с особой структурой данных – древом ячеек. Ячейка включает массив до 1023 бит информации или до четырех ссылок на соседние ячейки (256 бит каждая). Благодаря ссылкам на соседние ячейки выстраивается древовидная структура. Часть ячейки с определенным «отрезком» данных и частью ссылок отдельно взятой ячейки называется – cell slice или просто slice. Это самостоятельная структура данных со своими методами в Threaded Solidity.

Целую ячейку можно сравнить с другой ячейкой (операторы сравнения = и != -> bool), узнать глубину дочерних ячеек (количество ячеек, ссылающихся на взятую ячейку напрямую и опосредованно) – <TvmCell>.depth().

Конструктор пустой ячейки – TvmCell() – можно также использовать, чтобы узнать, является ли отдельно взятая ячейка пустой: if (cell == TvmCell()) {

Метод, который позволяет рассчитать объем данных, количество дочерних ячеек и количество ссылок для отдельно взятой ячейки: <TvmCell>.dataSize(uint n)

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

Во избежание исключения можно воспользоваться методом <TvmCell>.dataSizeQ(uint n). При использовании этого метода вместо исключения вернется пустое значение:

<TvmCell>.dataSizeQ(uint n) -> (optional(uint /*cells*/, uint /*bits*/, uint /*refs*/))

Как можно видеть, полные ячейки не обладают достаточным количеством методов для полноценной работы с содержащимися в них данными. В языке Threaded Solidity работа с данными подразумевает в основном использование типа slice.

Преобразовать ячейку в слайс можно воспользовавшись следующим методом ячейки: <TvmCell>.toSlice() .

Слайсы можно полноценно сравнивать друг с другом – поддерживаются следующие операторы <=, <, ==, !=, >=, >. Кроме того, слайсы можно преобразовывать в байты.

У слайсов есть свои методы на проверку наличия в них данных, счетчик ссылок, счетчик битового веса и методы dataSize() и dataSizeQ() как у целых ячеек.

Для получения данных из других слайсов, аргументов функций, статических переменных, в том числе из других контрактов, используются методы:
<TvmSlice>.load{...}(), <TvmSlice>.preLoad{...}(),
<TvmSlice>.load{...}Q(), <TvmSlice>.preLoad{...}Q.

В отличие от load(), preLoad() не изменяет слайс.

Пример загрузки статических переменных из поля data контракта А:

contract A {
	uint a = 111;
	uint b = 22;
	uint c = 3;
	uint d = 44;
	address e = address(12);
	address f;
}

contract B {
	function f(TvmCell data) public pure {
		TvmSlice s = data.toSlice();
		(uint256 pubkey, uint64 timestamp, bool flag,
			uint a, uint b, uint c, uint d, address e, address f) = s.loadStateVars(A);
			
		// pubkey - публичный ключ контракта A
		// timestamp - защита от повторного воспроизведения	
           // flag - всегда true
		// a == 111
		// b == 22
		// c == 3
		// d == 44
		// e == address(12)
		// f == address(0)
		// s.empty()
	}
}

Другой тип ячейки – TVM cell builder – билдер представляет собой «неполную» ячейку, которая берет данные сверху стека и быстро сериализует их, создавая новую ячейку или слайс. У билдера есть уникальные методы для расчета оставшегося места для загрузки данных и доступных слотов для добавления ссылок:
<TvmBuilder>.remBits() -> (uint16);
<TvmBuilder>.remRefs() -> (uint8);
<TvmBuilder>.remBitsAndRefs() -> (uint16 /*bits*/, uint8 /*refs*/).

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

О пространстве имен tvm

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

tvm.accept() – инструкция выставляет лимит газа до максимального значения. Это необходимо для обработки внешних сообщений, к которым может быть не приложена сумма в EVER для покрытия комиссия работы TVM. Типы сообщений мы раскроем подробнее в разделе о сообщениях.

tvm.commit() – создает снепшот статических переменных (копируя переменные из регистра c7 в регистр c4) и регистра c5, к которым откатывает состояние смарт-контракта в случае вызова исключений при исполнении кода. Фаза работы TVM в этом случае все равно считается успешно завершенной. Если в ходе выполнения кода смарт-контракта не возникло исключений, инструкция не возымеет никакого эффекта на транзакцию.

Регистры Threaded Virtual Machine

Регистры (control registers) – места в памяти виртуальной машины, отведенные под хранение определенных неизменных структур данных, к которым требуется постоянный доступ для выполнения функций. Хранение этих данных на стеке потребовало бы множественных операций по их перемещению, что негативно сказывается на производительности. TVM поддерживает использование 16 регистров, однако на практике используются регистры 0-5 и 7.

Мы затронули операции с регистрами c4, c5 и c7:

  • c4 хранит ячейку, указывающую на корень древа ячеек смарт-контракта, в которой хранятся данные контракта, записанные в состояние сети;

  • с5 хранит ячейку с данными о действиях, производимых после выполнения кода смарт-контракта. Если результатом выполнения кода контракта должна стать отправка сообщения, данные сообщения записываются в ячейку, хранящуюся в регистре c5;

  • c7 хранит временные данные. Данные хранятся в кортеже, который инициализируется через обращение к пустому кортежу и удаляется после завершения выполнения кода смарт-контракта.

tvm.rawCommit() – то же, что tvm.commit(), однако не копирует статические переменные из регистра c7 в регистр c4, что может привести к потере данных при использовании этой функции после tvm.accept() во время обработки внешних сообщений.

tvm.setData(TvmCell data) – сохраняет данные ячейки в регистре c4. Может использоваться в сочетании с tvm.rawCommit():

TvmCell data = ...;
tvm.setData(data); // сохраняем ячейку в регистр c4
tvm.rawCommit();   // сохраняем регистры c4 и c5
revert(200);       // вызываем исключение для завершения транзакции

tvm.getData() -> (TvmCell) – возвращает данные из регистра c4. Может пригодиться для апгрейда контракта.

Авторизация внешних сообщений

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

tvm.pubkey() -> (uint256) – возвращает публичный ключ из поля data смарт-контракта. Если ключ не был записан, вернет 0.

tvm.setPubkey(uint256 newPubkey) – записывает публичный ключ в поле data смарт-контракта.

tvm.checkSign() – вычисляет были ли данные, передаваемые в качестве аргумента, подписаны передаваемым публичным ключом. Возвращает булево значение.

tvm.insertPubkey() – вставляет в поле data ячейки со stateInit передаваемый в качестве аргумента публичный ключ.

tvm.initCodeHash() – возвращает хеш исходного кода смарт-контракта, актуального на момент его деплоя в сеть. Может использоваться, например, для вычисления кода TIP3 токена при проверке того, какой именно токен вам пытаются отправить.

В асинхронном блокчейне просчитать комиссии заранее – проблематично

Поскольку мы не можем знать наверняка, будет ли транзакция успешно выполнена или ее выполнение окончится ошибкой (а в сети Everscale или Venom операции могут подразделяться на множество отдельных транзакций), мы не можем точно определить, какое количество газа необходимо приложить к первоначальному сообщению. В качестве защитных механизмов от вредоносных эксплойтов и для удобства разработчиков у Threaded Virtual Machine (TVM) есть инструкции для ограничения суммы газа, затрачиваемой на выполнение транзакции.

tvm.setGasLimit(uint g) – устанавливает лимит газа для обработки одной транзакции. Данная функция позволяет ограничить расход средств на оплату работы TVM, что может быть полезно, если мы хотим написать защиту от спама внешними сообщениями с украденного аккаунта. В случае если параметр функции превосходит максимальное значение лимита газа, прописанное в конфигурации сети, функция отработает так же как tvm.accept(), поскольку лимит газа выставляется по следующей формуле: min(g,gmax)

tvm.buyGas(uint value) – инструкция рассчитывает сумму газа, которую можно приобрести за приложенную сумму наноэверов. Далее отрабатывает как tvm.setGasLimit().

tvm.rawReserve(uint value, uint8 flag) – функция используется, чтобы зарезервировать указанную сумму в наноэверах и отправить ее себе. Может быть полезно, чтобы ограничить расход газа на последующие исходящие вызовы.

Перед тем как привести варианты флагов, следует обозначить, что исходный баланс контракта (original_balance) это баланс контракта до исполнения транзакции на TVM с уже учтенной storageFee. Остаточный баланс (remaining balance) – сумма на балансе контракта после выполнения работы TVM и части исходящих вызовов (остаточный баланс после одного исходящего вызова больше чем остаточный баланс после трех исходящих вызовов. Всего может быть совершено до 255 исходящих вызовов, то есть исполнение кода отдельного смарт-контракта может привести к отправке максимум 255 исходящих сообщений).

Итак, флаги:

  • 0 -> reserve = value nEVER.

  • 1 -> reserve = remaining_balance - value nEVER.

  • 2 -> reserve = min(value, remaining_balance) nEVER.

  • 3 = 2 + 1 -> reserve = remaining_balance - min(value, remaining_balance) nEVER.

  • 4 -> reserve = original_balance + value nEVER.

  • 5 = 4 + 1 -> reserve = remaining_balance - (original_balance + value) nEVER.

  • 6 = 4 + 2 -> reserve = min(original_balance + value, remaining_balance) = remaining_balance nEVER.

  • 7 = 4 + 2 + 1 -> reserve = remaining_balance - min(original_balance + value, remaining_balance) nEVER.

  • 12 = 8 + 4 -> reserve = original_balance - value nEVER.

  • 13 = 8 + 4 + 1 -> reserve = remaining_balance - (original_balance - value) nEVER.

  • 14 = 8 + 4 + 2 -> reserve = min(original_balance - value, remaining_balance) nEVER.

  • 15 = 8 + 4 + 2 + 1 -> reserve = remaining_balance - min(original_balance - value, remaining_balance) nEVER

Остальные флаги – невалидны.

Обновление кода смарт-контрактов на Threaded Solidity двумя функциями

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

tvm.setcode(TvmCell newCode) – обновляет код смарт-контракта до версии, содержащийся в передаваемой ячейке. Изменения вступят в силу после завершения текущей сессии выполнения кода смарт-контракта.

tvm.setCurrentCode(TvmCell newCode) – изменяет код смарт-контракта на время текущей сессии работы работы TVM. После исполнения кода смарт-контракта изменения потеряют силу, и следующая транзакция будет исполняться согласно исходной версии кода.

Можно обновить код уже в рамках текущей транзакции и сохранить его, передав в сообщении код с поочередным вызовом tvm.setCurrentCode() и tvm.setcode(). Текущая транзакция отработает согласно новой версии кода благодаря tvm.setCurrentCode(), а tvm.setcode() перезапишет код смарт-контракта для последующих транзакций.

Глобальные конфиги

Глобальные настройки блокчейна хранятся в мастерчейне (описывается далее в статье). С помощью глобальных конфигов задаются некоторые параметры работы сети, например, количество валидаторов, настройки комиссии и прочее.

tvm.configParam(uint8 paramNumber) и tvm.rawConfigParam(uint8 paramNumber) – возвращают параметры из глобальной конфигурации. 

Возвращаемые структуры данных функций отличаются: configParam() возвращает типизированные значения, configParamRaw() возвращает ячейку и булево значение. Индексы глобальных параметров, передаваемые в качестве аргумента функции (paramNumber): 1, 15, 17, 34.

Методы для деплоя смарт-контракта

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

tvm.buildStateInit() – генерирует исходное состояние контракта (stateInit) из кода и данных, передаваемых в виде ячеек.

tvm.buildDataInit() – генерирует поле data исходного состояния смарт-контракта

tvm.stateInitHash() – возвращает хеш исходного состояния смарт-контракта.

Дополнительные функции пространства имен tvm

API компилятора Threaded Virtual Machine, помимо перечисленных выше и ниже, имеет методы для расчета случайных значений и проведения алгебраических операций. С ними вы можете ознакомиться в документации к API компилятора.

Мы же опишем еще несколько полезных специфических для написания смарт-контрактов методов.

tvm.hash() – возвращает 256-битный хеш данных, переданных в качестве аргумента. В случае если в качестве аргумента передаются данные типа bytes или string, хешируются не сами данные а древо ячеек, которое эти данные содержит.

tvm.code() -> (TvmCell) – возвращает код контракта.

tvm.codeSalt(TvmCell code) -> (optional(TvmCell) optSalt) – если в коде используется соль, тогда возвращаемая ячейка optSalt будет содержать значения, в противном случае вернется пустое значение.

Соль?

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

tvm.setCodeSalt(TvmCell code, TvmCell salt) -> (TvmCell newCode) – добавляет в код соль, передаваемую в виде ячейки, и возвращает новый код.

tvm.resetStorage() – возвращает значения статических переменных к значениям по умолчанию.

tvm.functionId() – возвращает беззнаковое 32-битное число, являющееся номером функции с областью видимости public или external или конструктора смарт-контракта. Используется, например, при описании логики поведения смарт-контракта при получении bounce сообщения. Про обработку bounce сообщений читайте в соответствующем разделе.

tvm.log() – аналог функции print(). Строки длиной более 127 символов обрезаются.

tvm.log(string log);
logtvm(string log)    // альтернативная по форме функция, работает так же

tvm.hexdump() и tvm.bindump() – вывод в консоль данных ячейки или целого числа в двоичном или шестнадцатеричном форматах.

tvm.exit() и tvm.exit1() – использование функций сохраняет статические переменные и завершает выполнение смарт-контракта с кодом 0 и 1 соответственно.

Функции TVM для сообщений

tvm.buildIntMsg() – генерирует исходящее внутреннее сообщение, которое должно вызвать выполнение другой функции у контракта-получателя. Возвращает ячейку, которую можно использовать в качестве аргумента функции tvm.sendrawmsg().

tvm.sendrawmsg(TvmCell msg, uint8 flag) – отправить внешнее/внутреннее сообщение с переданным флагом. Внутреннее сообщение можно сгенерировать функцией tvm.buildIntMsg() выше. Возможные варианты флагов описаны в функции address.transfer(). При передачи msg необходимо убедиться, что аргумент имеет корректный формат.

tvm.encodeBody(function, arg0, arg1, arg2, ...) -> (TvmCell) – кодирует тело сообщения, которое затем может использоваться в качестве аргумента функции address.transfer(). Если функция responsible, необходимо также передать функцию-коллбек.

В TVM-блокчейнах смарт-контракты общаются сообщениями

Все операции в TVM-совместимых сетях инициируются получением смарт-контрактом входящего сообщения. 

Сообщения бывают:

  • Внешними и внутренними:

    • Внешние сообщения отправляются извне сети Everscale или предназначены для получателей вне сети;

    • Внутренние сообщения отправляются и получаются смарт-контрактами внутри сети;

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

  • Входящими и исходящими.

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

В пространстве имен сообщений (msg) есть следующие функции:

msg.sender (address) – возвращает адрес отправителя внутреннего сообщения или 0 в случае внешнего сообщения или tick/tock транзакции.

Tick/Tock транзакции

Tick/Tock транзакции – служебные транзакции, отправляемые для синхронизации и проверки работы некоторых смарт-контрактов.

msg.value (uint128) – возвращает сумму, приложенную к внутреннему сообщению в наноэверах (nEVER). В случае внешнего сообщения возвращает 0; в случае tick/tock транзакции возвращает undefined.

msg.pubkey() -> (uint256) – возвращает публичный ключ, которым было подписано внешнее сообщение. В случае внутреннего сообщения вернет 0, также вернет 0, если внешнее сообщение не было подписано (внешним сообщением вызывается функция смарт-контракта, не требующая валидации актора). Важный метод, с помощью которого валидируется актор, запрашивающий исполнение кода смарт-контракта.

msg.createdAt (uint32) – возвращает время создания внешнего сообщения.

msg.data (TvmCell) – возвращает все данные, включенные в сообщение (хедер и тело).

msg.body (TvmSlice) – возвращает тело сообщения.

msg.hasStateInit (bool) – возвращает булево значение в зависимости от того, содержит ли сообщение поле stateInit.

msg.forwardFee (varUint16) – возвращает значение forwardFee взимаемого за отправку внутреннего сообщения.

forwardFee

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

Total action fee – общая комиссия, рассчитываемая из всех отправленных сообщений (действий смарт-контракта, инициированных в результате выполнения текущей транзакции), не взимается, если исполнение кода смарт-контракта не привело к отправке ни одного сообщения.

msg.importFee (varUint16) – возвращает значение importFee, взимаемого за обработку внешнего сообщения. Вероятнее всего не отражает реального значения, поскольку выставляется пользователем вне сети.

Модификаторы externalMsg и internalMsg:
Данные модификаторы определяют сообщения какого типа могут вызывать определяемую функцию. Отсутствие модификатора позволяет вызывать функцию как внешними, так и внутренними сообщениями.

Адреса в TVM-совместимых блокчейнах

Тип address представляет несколько видов адресов, с которыми работает TVM: 

  • addr_none – адрес, который еще не был развернут в сети; 

  • addr_extern – адрес, не принадлежащий сети Everscale.

  • addr_std  – стандартный адрес;

  • addr_var – любой адрес.

Конструктор address(address_value) создает стандартный адрес в воркчейне 0. Блокчейны на Threaded Virtual Machine (TVM) – гетерогенные, то есть один мастерчейн (воркчейн -1) может объединять до 232 воркчейнов, каждый из которых может гибко настраиваться. Если грубо перефразировать, то это как если бы решения второго уровня существовали непосредственно внутри Ethereum.

Создаем особый адрес

Создание адреса определенного вида / в необходимом воркчейне:

address addrStd = address.makeAddrStd(wid, address);

address addrNone = address.makeAddrNone();

address addrExtern = address.makeAddrExtern(addrNumber, bitCnt);

Некоторые служебные функции:

<address>.wid – возвращает номер воркчейна, в котором развернут адрес, в случае если адрес не относится к видам addr_std или addr_var, вызывается исключение range check error.

<address>.value – возвращает 256-битное значение адреса для видов addr_std и addr_var (если addr_var имеет 256-битное значение). В противном случае вызывается исключение range check error.

<address>.balance – возвращает баланс на адресе.

<address>.currencies – возвращает валюты на адресе.

Трансфер и флаги

Для отправки исходящего сообщения на необходимый адрес используется функция:

<address>.transfer(
uint128 value, 
bool bounce, 
uint16 flag, 
TvmCell body,
TvmCell stateInit
);

Любой параметр кроме value можно опустить.

Параметры:

  • value – сумма в нативной монете, прикладываемая к сообщению. Будет расходоваться на оплату комиссий сети. Поскольку исполнение кода асинхронно пользователь не может рассчитать точную сумму газа, необходимую для завершения операции.

  • bounce – если флаг выставлен true и транзакция досрочно завершается с ошибкой, отправленные средства должны вернуться на баланс к отправителю. Выставление флага false приведет к доставке средств на выбранный адрес, даже если он не существует или заморожен. Один из случаев, когда вам может понадобиться выставлять bounce = false – деплой нового контракта с одновременной пересылкой средств на него.

  • flag – стандартное значение 0. Означает, что к сообщению прикреплено столько средств, сколько указано в параметре value. Другие значения параметра flag:

    • 128 – к сообщению приложен весь остаток с баланса контракта, который после доставки сообщения станет равен 0;

    • 64 – к сообщению приложено столько EVER, сколько указано в параметре value и вся сумма полученная от входящего сообщения (которое инициировало исполнение кода смарт-контракта);

    • flag + 1 (например, 64 + 1) – означает, что отправитель хочет оплатить комиссии отдельно от баланса адреса;

    • flag + 2 – все ошибки, возникающие во время фазы action должны быть проигнорированы (фаза action – фаза, когда инициируется исполнение действий по обновлению состояния сети после проведения расчетов на TVM);

    • flag + 32 – удаление адреса из сети если его баланс равен нулю. Сообщение с флагом 128 + 32 означает, что смарт-контракт отправил весь доступный баланс и будет удален из сети.

  • body – тело сообщения. По умолчанию пустая TVM-ячейка;

  • currencies – дополнительные валюты приложенные к сообщению. Используется для отправки TIP3 токенов.

  • stateInit – поле init стандартного сообщения. Если stateInit отправляется в неверном формате, вызывается исключение cell underflow. Обычно stateInit отправляется при деплое контракта или его разморозке.

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

Блоки и транзакции

В сетях Everscale и Venom блоки могут быть либо мастер-блоками (блоками мастерчейна) либо блоками воркчейна. В мастер-блоки записываются хеши всех блоков воркчейна, финализированных за отведенное время сборки мастер-блока.

Блоки воркчейнов содержат информацию о сообщениях и транзакциях от всех смарт-контрактов из всех тредов воркчейна.

Транзакция – это запись о выполнении кода смарт-контракта.

В API компилятора есть методы, позволяющие получить время блока/транзакции и логическое время блока/транзакции.

block.timestamp -> (uint32) – возвращает время в UNIX формате для текущего блока. Всем транзакциям из блока присваивается это значение времени.

block.logicaltime -> (uint64) – возвращает начальную точку логического времени для текущего блока, то есть точку начала отсчета логического времени блока.

tx.logicaltime -> (uint64) – возвращает логическое время текущей транзакции.

tx.storageFee -> (uint120) – возвращает значение storageFee, оплаченной во время выполнения данной транзакции.

Если с временем в UNIX формате все предельно понятно, то логическое время (logical time, LT) необходимо рассмотреть подробнее. Оно не имеет никакого отношения ко времени в привычном понимании. Логическое время обеспечивает строгий порядок доставки сообщений от одного смарт-контракта к другому.

Логическое время нового мастер-блока всегда на 1000000 больше времени предыдущего мастер-блока. Таким образом, все сущности, зависящие от мастер-блока будут иметь меньшее логическое время чем сам мастер-блок.

Логическое время нового блока треда равно логическому времени последнего мастер-блока + 1000000. Таким образом, в начальной точке логическое время будущего блока треда равно логическому времени будущего мастер-блока (блок треда ссылается на предыдущий мастер-блок и добавляет 1000000 к его логическому времени, в то время как будущий мастер-блок автоматически прибавляет 1000000 ко времени предыдущего мастер-блока).

Логическое время транзакции равно:
tx.LT = max(block.LT, msgInbound.LT, prevTx.LT) + 1

Логическое время сообщения равно:
msg.LT = max(tx.LT, txPrevMsg.LT) + 1

Итого, если смарт-контракт отправляет одному адресату два сообщения, доставлены и исполнены они будут строго в порядке их отправки.

Интересные особенности:

  • Два сообщения от разных смарт-контрактов разным адресатам могут иметь одинаковое логическое время.

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

  • Строгий порядок получения сообщений не гарантирован в случае, если контракт A отправляет отправляет два сообщения контракту Б и контракту В, после исполнения кода которых от обоих контрактов будет отправлено по сообщению контракту Г. Если контракты Б и В находятся в разных тредах, то время отправки сообщений будет зависеть от загруженности треда. Данную особенность асинхронной архитектуры следует держать в уме при написании смарт-контрактов. И хотя это может показаться серьезной проблемой, написание максимально самостоятельных, с не зависящей друг от друга логикой смарт-контрактов поможет избежать негативных последствий. Риск получения ошибок с лихвой компенсируется тем, что именно асинхронная архитектура TVM-сетей позволяет добиться высокой пропускной способности, то есть высокой степени масштабируемости, без ущерба децентрализации и безопасности блокчейна.

ABI – абстрактный бинарный интерфейс

Ключевое слово pragma используется в том числе для объяснения компилятору какие хедеры будут включены в .abi файл смарт-контракта. ABI – акроним от Abstract Binary Interface – абстрактный бинарный интерфейс, который необходим для построения корректного алгоритма преобразования данных из сообщения в древо ячеек для дальнейшей работы TVM. Хедеры необходимы для адекватной обработки внешних сообщений (сообщений, отправляемых извне сети Everscale или Venom).

Следующие хедеры могут быть включены в abi-файл:

  • timestamp – время отправки внешнего сообщения. Необходимо для защиты от повторного воспроизведения;

  • expire – время жизни сообщения, по умолчанию равно timestamp + 2 минуты;

  • signature – подпись сообщения публичным ключом. Внешние сообщения можно подписывать публичным ключом, чтобы вызывать функции у смарт-контрактов, требующие валидации пользователя (внутренние сообщения не подписываются поскольку отправляются смарт-контрактами. Смарт-контракты валидируют друг друга по адресу, который, по сути, является хешом кода смарт-контракта и статических переменных). В коде смарт-контракта должно быть явно указано, что функция может вызываться приемом внешнего сообщения, и если в abi-файле смарт-контракта есть данный заголовок, то все внешние сообщения будут проверяться на наличие подписи.

Так выглядит тело сообщения внутри abi-файла:

{
  "name": "set",
  "inputs": [{"name":"_value","type":"uint256"}],
  "outputs": []
},

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

abi.encode(TypeA a, TypeB b, ...) -> (TvmCell /*cell*/) – кодирует переменные заданных типов и возвращает ячейку.

abi.decode(TvmCell cell, (TypeA, TypeB, ...)) -> (TypeA /*a*/, TypeB /*b*/, ...) – декодирует ячейку и возвращает переменные заданных типов. Не все типы можно передавать в качестве аргументов. В случае передачи некорректного типа, функция вызовет исключение.

Деплой и апгрейд

В разделе о функциях tvm мы уже касались процесса апгрейда и деплоя смарт-контрактов на Threaded Solidity. Рассмотрим его подробнее.

Адрес контракта вычисляется хешированием stateInit. Чтобы удостовериться, что адрес нового смарт-контракта уникален, необходимо подписать внешнее сообщение, использующееся для деплоя контракта. В случае внутреннего сообщения (деплой нового контракта через существующий смарт-контракт) необходимо передать в конструктор адрес вызывающего смарт-контракта (адрес владельца нового смарт-контракта).

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

Ключевые слова new, stateInit и code

Ключи stateInit и code должны использоваться при деплое смарт-контракта в сеть с помощью функции с ключевым словом new.

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

stateInit – определяет первоначальное состояние нового контракта.

code – определяет только код нового смарт-контракта.

...
contract SimpleWallet {
    address static m_owner;
    uint static m_value;
    ...
}

// файл контракта, развертывающего контракт SimpleWallet в сеть
TvmCell code = ...;
address newWallet = new SimpleWallet{
    value: 1 ever,
    code: code,
    pubkey: 0xe8b1d839abe27b2abb9d4a2943a9143a9c7e2ae06799bd24dec1d7a8891ae5dd,
    splitDepth: 15,
    varInit: {m_owner: address(this), m_value: 15}
}(arg0, arg1, ...);

Обратите внимание на переменные, передаваемые в new:

  • value – количество EVER, которые будут приложены к сообщению о деплое;

  • currencies – дополнительные валюты (токены), которые будут приложены к сообщению о деплое;

  • bounce – по умолчанию выставлен true – средства, отправленные с сообщением о деплое контракта, при возникновении исключения во время фазы работы TVM будут возвращены на баланс контракта-отправителя сообщения о деплое. Чтобы средства остались на новом смарт-контракте, необходимо передать флаг bounce = false;

  • wid – номер воркчейна, в котором должен быть развернут новый адрес; по умолчанию присвоен 0. Сегодня в сети Everscale развернут только один воркчейн под номером 0;

  • flag – по умолчанию 0. Подробнее о флагах, проставляемых в параметрах исходных сообщений мы писали в разделе <address>.transfer().

Задеплоить контракт в сеть можно также с помощью функции <address>.transfer(). Для этого в параметры функции необходимо просто передать stateInit нового контракта.

Обновление кода смарт-контракта

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

Обновление смарт-контракта TIP3 токена
// Если владелец знает, что в Рут контракте есть новая версия кода, 
// он может запросить апгрейд для себя.
  function upgrade(address remainingGasTo) override external onlyOwner {
    ITokenRootUpgradeable(root_).requestUpgradeWallet{ 
      value: 0, 
      flag: TokenMsgFlag.REMAINING_GAS, 
      bounce: false 
    }(
      version_,
      owner_
    );
  }

  // В ответ он получает новую версию кода 
  function acceptUpgrade(TvmCell newCode, uint32 newVersion) override external onlyRoot {
    if (version_ != newVersion) {
      // Кодируем весь стейт контракта в TvmCell + новую версию
      TvmCell state = abi.encode(root_, owner_, balance_, version_, newVersion);
      // Устанавливаем контракту новый код начиная со следующей транзакции
      tvm.setcode(newCode);
      // Устанавливаем новый код прямо в текущей транзакции
      tvm.setCurrentCode(newCode);
      // Вызываем функцию onCodeUpgrade уже нового кода.
      onCodeUpgrade(state);
    }
  }
}

// Функция onCodeUpgrade в новой версии контракта, после setCurrentCode
// можно вызвать только ее.

function onCodeUpgrade(TvmCell data) private {
  // Сбрасываем сторадж контракта в 0, потому что если мы 
  // добавили какую-нибудь переменную, у нас бы изменилась
  // структура стораджа. Этот вызов не трогает служебные 
  // переменные _pubkey, _replayTs, _constructorFlag, 
  // остальные переменные во временном регистре c7 он
  // инициализирует нулями.
  tvm.resetStorage();
  
  // декодируем стейт 
  (address root, address owner, uint128 balance, uint32 fromVersion, uint32 newVersion) =
        abi.decode(data, (address, address, uint128, uint32, uint32));

  // инициализируем стейт
  root_ = root;
  owner_ = owner;
  balance_ = balance;
  version_ = newVersion;
}

tvm.setcode(TvmCell newCode) – обновляет код смарт-контракта до версии, содержащийся в пересылаемой ячейке. Изменения вступят в силу после завершения выполнения текущего кода смарт-контракта.

Работа с исключениями fallback() и onBounce()

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

Метод fallback() определяет поведение контракта при получении невалидного сообщения:

  • В сообщении вызывается функция, id которой отсутствует в коде смарт-контракта;

  • Длина сообщения в битах находится в диапазоне от 1 до 31 бита включительно;

  • Длина сообщения – 0 бит, однако в сообщение включены ссылки.

Метод onBounce() определяет поведение контракта при получении bounce сообщения, генерируемое сетью, если в исходящем сообщении был проставлен флаг bounce = true, о котором мы писали в разделе <address>.transfer(). Сообщение типа bounce также генерируется сетью если:

  • смарт-контракт получатель сообщения не был задеплоен в сеть;

  • вызываемый смарт-контракт не смог исполнить свой код.

Bounce сообщение может быть отправлено только если суммы EVER, приложенной к исходному сообщению хватит на покрытие сетевой комиссии.

tvm.functionId() – возвращает беззнаковое 32-битное число, являющееся номером функции с областью видимости public или external или конструктора смарт-контракта. Используется, например, при описании логики поведения смарт-контракта при получении bounce сообщения:

	onBounce(TvmSlice slice) external {
		// Повышаем счетчик полученных bounce сообщений.
		bounceCounter++;

		// Декодируем сообещние. В первых 32 битах хранится 
              id функции.
		uint32 functionId = slice.load(uint32);

		// tvm.functionId() рассчитывает id по имени функции.
		if (functionId == tvm.functionId(
                   AnotherContract.receiveMoney
                   )
              ) {
		// loadFunctionParams() загружает параметры функции
              из слайса.
		// После декодирования параметры функции сохраняем  
              в статические переменные.
			invalidMoneyAmount = slice.loadFunctionParams(AnotherContract.receiveMoney);
		} else if (functionId == tvm.functionId(AnotherContract.receiveValues)) {
			(invalidValue1, invalidValue2, invalidValue3) = slice.loadFunctionParams(AnotherContract.receiveValues);
		}
	}

Другие особенности языка Threaded Solidity

Прокачанный try-catch

Обработка исключений с помощью try-catch в коде смарт-контракта в отличие от Solidity работает не только в случаях вызовов внешними функциями или при создании нового контракта.

Модификатор responsible

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

Удаление контракта

selfdestruct(address dest_addr) – отправляет с сообщением все средства с баланса смарт-контракта на переданный адрес и удаляет данный аккаунт.

Новый протокол – старый язык

Несмотря на то, что в статье было перечислено немало особенностей API компилятора и самого протокола Everscale, мы все же полагаем, что написание кода смарт-контрактов на Threaded Solidity сегодня – удобно и понятно для разработчиков на стандартном Solidity.

Если вас вдохновили возможности современных TVM-совместимых блокчейнов или вам захотелось подробнее изучить код смарт-контрактов, написанных для выполнения в асинхронной парадигме в соответствии с моделью акторов, мы приглашаем вас в открытые репозитории с примерами смарт-контрактов для ознакомления с различными юзкейсами в обучающем формате или в наш Github с реальными решениями (Flatqube, Octus Bridge, Gravix), функционирующими в мейннете на протяжении уже нескольких лет.

Теги:
Хабы:
Всего голосов 7: ↑7 и ↓0+7
Комментарии7

Публикации

Истории

Работа

Ближайшие события

2 – 18 декабря
Yandex DataLens Festival 2024
МоскваОнлайн
11 – 13 декабря
Международная конференция по AI/ML «AI Journey»
МоскваОнлайн
25 – 26 апреля
IT-конференция Merge Tatarstan 2025
Казань