В последнее время многие блокчейн-платформы для исполнения смарт-контрактов переключились на WASM — WebAssembly. Мы не стали исключением, и в последнем обновлении тоже добавили WebAssembly как альтернативу привычному Docker. В этом посте мы расскажем, для каких задач нам потребовался именно WASM, что мы достигли с ним на сегодня и как WASM отражается на производительности блокчейна.
![](https://habrastorage.org/getpro/habr/upload_files/17a/93b/861/17a93b861fe7c3bcf06beda2cb0e8484.png)
Что такое WebAssembly? Это переносимый бинарный формат для исполнения программ, известный с 2015 года. Изначально основной целью WASM — исполнять более компактный и быстрый код в вебе, в клиенте браузера. Разработчикам удалось ее достичь, но на этом WebAssembly не остановился. Сегодня он уже дорос до расширения WebAssembly System Interface: оно позволяет создавать виртуальные машины, вполне способные конкурировать с зарекомендовавшими себя решениями типа JVM, .NET, BEAM.
Для WebAssembly нашлось применение не только в браузерах, но и в блокчейне, для смарт-контрактов. WebAssembly Runtime Environments (RE) даже использует в названии Assembly, а байт-код здесь представляет собой некий набор инструкций, что роднит его с другими ассемблерами. Но фактически это стековая машина, где регистр не используется и мы оперируем четырьмя типами значений — i32, i64, f32, f64.
С более сложными типами данных работает линейная память, в которой хранятся нужные данные самого байт-кода. Они собираются в виде условной «кучи», которая может расти до определенных пределов, исходя из ограничений самого байт-кода и виртуальной машины, на которой он исполняется.
![Базовая структура WebAssembly Базовая структура WebAssembly](https://habrastorage.org/getpro/habr/upload_files/450/b6d/383/450b6d383b1e30043618fa167f7979aa.png)
Для удобства чтения и редактирования в WebAssembly предусмотрен текстовый формат. Он представляет собой одно большое S-выражение, имеет Lisp-подобный синтаксис, напоминая Common Lisp и Scheme:
(module
(func (param $lhs 132) (param $rhs 132) (result 132)
local get $lhs
local get $rhs
132. add))
Текстовый формат позволяет нам читать и при необходимости редактировать байт-код, понимая, какие инструкции и как в нем используются.
Зачем нам нужен WASM
Есть несколько причин, по которым в принципе стоит обратить внимание на WebAssembly.
Как виртуальная машина для исполнения смарт-контрактов, WASM имеет очень высокую производительность.
Байт-код сам по себе имеет очень малый размер, что важно для блокчейна, поскольку так мы можем не раздувать транзакции и стейт.
Любой рантайм по факту является изолированной средой. В базовом представлении WASM не имеет доступа ни к чему снаружи: в него загружается байт-код, который затем исполняется.
На WASM можно добиться детерминированного исполнения: для этого достаточно просто отказаться от использования значений с плавающей точкой.
WASM поддерживает компиляцию множества языков, что позволяет разрабатывать смарт-контракты на любом языке программирования.
Эти причины частично пересекались и с нашими собственными:
Мы хотели увеличить производительность по сравнению с текущими докер-контрактами. Было понятно, что это сработает, так как в докере уходит очень много времени на сетевое общение контрактов с нодой, поднятие контейнеров и многое другое.
Мы хотели реализовать вызов одним смарт-контрактом методов другого, но по тем же причинам, что и в предыдущем пункте, в докере такие вызовы не представляются реалистичными.
При этом важно было не допустить сильного увеличения размера транзакции. Байт-код нам придется хранить он-чейн, поэтому минимизация байт-кода стала очень приоритетна: как можно меньше инструкций в самом байт-коде, как можно больше делегирования инструкций и тяжелых вычислений на уровень выше.
Наконец, важно было не сломать то, что уже работает. Поэтому в итоге мы не стали создавать новые транзакции, а обновили существующие, добавив в них необходимые поля для WASM-контрактов. Внутри ноды мы также постарались сохранить весь текущий флоу контрактов и просто подселили туда еще один движок для исполнения. Таким образом получилось сохранить все наработки докер-реализации.
Waves Enterprise Virtual Machine (WEVM)
Результатом работы с WASM стала Waves Enterprise Virtual Machine — новый движок для исполнения смарт-контрактов на нашей платформе. Здесь стоит пояснить: хотя основным продуктом нашей компании является блокчейн-платформа «Конфидент», компоненты, общие с публичным блокчейн-протоколом Waves Enterprise, носят оригинальное название open-source проекта.
WEVM включает несколько компонентов:
интерпретатор WASM, который выполняет байт-код;
набор функций для работы с нодой, который расширяет интерпретатор и позволяет байт-коду взаимодействовать с нодой, выполнять перевод, создание ассетов, получение баланса и т. п.;
интерфейс для взаимодействия с нодой;
механизм для управления выполнением смарт-контрактами с целью вызова одним контрактом методов другого.
При исследовании мы поняли, что почти все существующие виртуальные машины и интерпретаторы разработаны на Rust, и решили тоже начать развитие WEVM с Rust. В качестве интерпретатора мы использовали wasmi, разработанный компанией Parity. По большей части его используют именно для смарт-контрактов, в нем нет ничего лишнего. Для общения виртуальной машиной и ноды используется Java Native Interface (JNI) и crate jni v.0.21.0.
Если вкратце, WEVM работает следующим образом. Майнер в сети запускает новую виртуальную машину, по аналогии с реализацией в докере. В нее майнер загружает транзакцию с байт-кодом из UTX-пула. Виртуальная машина возвращает результат, который используется майнером как результат выполнения смарт-контракта и, соответственно, транзакции.
Дополнительно мы разработали Rust CDK — небольшую eDSL, которая расширяет язык набором атрибутов, функций и много другого для удобного и понятного написания смарт-контрактов в нашей платформе:
#! [no_std]
#! [no_main]
use we_cdk::*;
// Declaring a function available for calling.
// ‘#[action]’ keyword is used for this purpose.
//_constructor mandatory method that is called during CreateContract Transaction.
#[action]
fn _constructor(init_value: Boolean) {
// Write the obtained value as an argument of the function into contract state.
set_storage! (boolean :: "value" = init_value);
}
#[action]
fn flip() {
// Read the value from the contract state.
let value: Boolean = get_storage!(boolean :: "value");
// Write the inverted value to the contract state.
set_storage! (boolean :: "value" => !value);
}
В состав Rust CDK входит расширение cargo-we для пакетного менеджера с утилитами для удобного создания и сборки проекта. В будущем мы хотим ее сильно расширять, и об этом я расскажу чуть позже.
![](https://habrastorage.org/getpro/habr/upload_files/05b/4c8/8f8/05b4c88f8d1813e4c7166cd489a083d7.png)
Эту реализацию WASM мы сравнивали с докером в разных бенчмарках, с шардингом и без, с включенным и выключенным MVCC и т. д. В лучшем для докера прогоне производительность сети в среднем достигала 50–60 tps — и это при 300–400 tps у WASM. В конце поста мы покажем бенчмарки конкретного смарт-контракта.
Пишем смарт-контракт для WASM
О том, как развернуть у себя опенсорсную версию нашей платформы, мы рассказывали в одном из предыдущих постов (и в другом, более доступно). CDK для смарт-контрактов на Rust доступен на гитхабе. Если интересно, можете повторить смарт-контракт, который я для примера напишу далее. И с помощью нашей утилиты cargo-we расскажу о некоторых его особенностях.
Для начала поставим эту утилиту, она есть в открытом доступе:
cargo install --git https://github.com/waves-enterprise/we-cdk.git --force
В дальнейшем мы планируем опубликовать утилиту не только на GitHub, но и в публичном репозитории пакетов Rust. В утилите доступна инициализация, сборка проекта и отладка в WASM и текстовом формате.
Создадим новый проект под названием flipper:
cargo we new flipper
Посмотрим, что внутри:
![](https://habrastorage.org/getpro/habr/upload_files/8e8/1ba/623/8e81ba6236c56b7df5c71ece9907c308.png)
Cargo.toml — это обычный файл манифеста, как package.json для JS и TypeScript. Он включает имя проекта, версия и все необходимые настройки. Так как CDK еще не опубликован, мы в последней строке вручную дописали, что его нужно ставить с гита:
[package]
name = "flipper"
version = "0.1.0"
edition = "2021"
[lib]
crate-type = ["cdylib"]
path = "lib.rs"
[profile.release]
codegen-units = 1
lto = true
opt-level = 'z'
panic = 'abort'
strip = true
[dependencies]
we-cdk = { verston = "0.1.1", git = "https://github.com/waves-enterprise/we-cdk.git" }
Также у нас автоматически создается gitignore и сам контракт — lib.rs:
#! [no_std]
#! [no_main]
use we_cdk::*;
#[action]
fn _constructor(init_value: Boolean) {
set_storage! (boolean :: "value" => init_value);
}
#[action]
fn flip() {
let value: Boolean = get_storage!(boolean :: “value”);
set_storage!(boolean :: "value" => !value);
}
Здесь используется базовый синтаксис Rust, ничего специфичного; no_std означает, что мы не используем стандартную библиотеку — с ней бинарник получится слишком объемным.
Импортируем собственную библиотеку и в lib.rs начинаем писать логику контракта. Возьмем для примера стандартную функцию и допишем атрибут action, чтобы показать, что она доступна снаружи при вызове контракта:
#[action]
fn test() {
}
Добавим параметры. В CDK описаны четыре стандартных типа данных, используемых в нашей платформе: integer, boolean, string и binary. Выберем из этого списка:
#[action]
fn test(value: Integer) {
}
Функции, доступные снаружи для вызова, не дают в ответ никаких результатов: это все аккуратно спрятано в CDK. Под капотом функция возвращает код ошибки, а если его нет, то возвращает ноль. Поэтому нам не нужно использовать возвращаемые значения, достаточно только описать аргументы.
В контракте обязательно должна присутствовать функция _constructor, помеченная как action, — ее можно найти в базовом коде lib.rs выше. Именно она вызывается через CreateContract Transaction. Даже если нам нечего написать в constructor, мы можем оставить ее пустой; тогда функция просто не будет ничего делать. Далее мы можем описывать любые функции контракта и использовать набор функций, доступный из CDK для работы с нодой: чтение и обновление значений контракта, работа с токенами.
Подход здесь несколько отличается от подхода смарт-контрактов в докере. Там контракт получает транзакцию на вход и выполняет действие в зависимости от заданных параметров. Здесь же при использовании транзакции CreateContract вызывается функция constructor. Она может быть только одна; в ней описывается некий код, который инициализирует состояние контракта. В остальных случаях может быть использован CallContract с указанием конкретных функций для вызова. Этот подход больше похож на подход в Solidity.
Вызов одного смарт-контракта другим
В CDK мы уже реализовали все базовые функции — создание, сжигание, перевыпуск, лизинг токенов и др. Отдельно я хочу показать, как реализовать вызов одного контракта другим — это именно то, что доступно в WASM и неприменимо в докер-реализации.
Сначала необходимо описать функции, которые мы будем вызывать. Вернемся к lib.rs и добавим в него стандартный интерфейс, как в Java. Пометим его атрибутом interface:
#[interface]
trait i_contract {
fn data_fn(value: Integer):
}
Далее мы можем вызвать функцию другого контракта. Делается это через макрос call_contract!: мы указываем интерфейс, который мы хотим использовать, и адрес контракта (base58), затем функцию, которую хотим вызвать. И наконец, передаем в нее аргументы.
call_contract! {
i_contract(base64! ("ADDRESS"))::data_fn(42)
}
К транзакции call_contract можно приложить платеж. Для этого используем константу системного токена SYSTEM_TOKEN. Также мы указываем, что хотим провести платеж совместно с вызовом. Вот как будет выглядеть итоговая функция flip:
#[action]
fn flip() {
let value: Boolean = get_storage!(boolean :: “value”);
set_storage!(boolean :: "value" => !value);
let payment: Payment = (SYSTEM_TOKEN, 42);
call_contract! {
i_contract(base58! ("ADDRESS"))::data_fn(42)::payments(payment)
}
}
Сначала выполнится логика, описанная в начале. Потом вызовется другой контракт, который выполнит действия. А в случае ошибки функция завершит свою работу с кодом ошибки, который произошел при выполнении другого контракта. Функция flip будет считаться успешно завершенной, если ошибок нет — это прописано в CDK. Нода получит эти данные, предоставит пользователю, и он сам примет решение.
Измеряем производительность WASM
Для бенчмарка мы запускали следующий контракт:
#! [no_std]
#! [no_main]
use we_cdk::*;
const PREFIX_KEY: String = "shard_";
const NUMBERS: String = "0123456789";
#[action]
fn _constructor() {
for i in 0..10 {
let x = NUMBERS.get_unchecked (i..i + 1);
let key = join!(string :: PREFIX_KEY, &x);
set_storage! (integer :: key => 0);
}
}
#[action]
fn increment_ 1(shard: String) {
let key = join!(string :: PREFIX_KEY, shard);
let counter = get_storage!(integer :: key);
set_storage!(integer :: key => counter + 1);
}
Код контракта простой, но довольно показательный. Здесь происходит два запроса к ноде: получить сторадж и записать сторадж. Логика простая: мы берем из стораджа номер шарда (счетчика) и перезаписываем его, увеличив на 1.
Вызовем bulid и получим байт-код контракта в бинарном виде:
![](https://habrastorage.org/getpro/habr/upload_files/f07/4ea/432/f074ea432ff00c654fea1cca10015675.png)
Нам также нужно получить SHA-сумму. Так мы удостоверимся в том, что задеплоили именно то, что хотели задеплоить:
![](https://habrastorage.org/getpro/habr/upload_files/d8c/e8c/00b/d8ce8c00b8a114e0192d49bca87cdc92.png)
Вот в таком виде хранится контракт. По API получить его очень просто:
![](https://habrastorage.org/getpro/habr/upload_files/ae0/77c/696/ae077c6969fc66316200ac01d82af823.png)
Вызовем транзакцию 103, создание контракта на ноде. Всё так же, как при работе с докером, только вместо imageHash используем bytecode и bytecodeHash:
{
"type": 103,
"version": 7,
"sender": "3Nremv58EXSYK2qa5bhMeGnm1f2pRqLnv34" ,
"contractName": "SomeContract",
"fee": 1000,
"storedContract": {"bytecode":
"AGFzbQEAAAABJQVgBH9/f38Df39/YAN/f34Bf2AEf39/fwJ/fmAAAX9gAn9/AX8CSgQDZW52BmlLbW9yeQIBAhAEZW52MARqb2LuAAAEZW52MA9zZXRfc3RvcmFnZV9pbnQAAQRLbnYwD2dLdF9zdG9yYwd
LX2LudAACAwMCAwQGEAN/AUEQC38AQSALfwBBIAsHOQQMX2NvbnN0cnVjdG9yAAMLaW5j cmVtZW50XzEABApfX2RhdGFfZW5kAwELX19oZWFwX2Jhc2UDAgrXAQJwAQR/QQAhAANAAkAgAEEKRw®AQQAPCwJAQZqAgLAAQQBBmoCAgABBBhCAg/AACECIQE1Aw@AIAEgA1AAQZCAgIAAaKEBEICAgIAAIQ1hASIDDQAgAEEBalEA|AEgAKIAEIGAgLAAIgNFDQELCyADC2QCA38BfgJAQZqAgIAAQQBBmoCAgABBBhCAgIC
AACEDIQIiBA0AIAIgAyAAIAEQgIAgAAhASEAIgQNAEEAQQAgACABEIKAgIAAIQUiBA0AIAgASAFQgF8EIGAgIAAIQQLIAQLCxYBAEEQCxAwMTIzNDU2Nzg5c2hhcmRf"
"bytecodeHash": "c2f116a528291d6cbcadc308edd8a1f294c4656009705916f3f0929150838388" },
"params" : [],
"apiVersion": "1.0",
"payments": [],
"validationPolicy": {
"type": "any"
}
Выполняем контракт, получаем в стейте заданные счетчики:
![](https://habrastorage.org/getpro/habr/upload_files/e84/7fa/1cc/e847fa1cc837c79b8c94abb9c1cb8df1.png)
Теперь почти так же, как с докером, мы отправляем транзакцию 104, вызов контракта:
{
“type": 104,
"version": 7,
"sender": "3Nremv58EXSYK2qa5bhMeGnm1f2pRqLnv34",
"contractName": "SomeContract"
"fee": 1000,
"contractld": "FAvaxdSddyyUdzuu8v518Rzowc2kcffRN4MY27g]fPYH",
"params": ["type": "string", "key": "shard", "value": "1"71.
"apiVersion": "1.0",
"contractVersion": 1,
"contractEngine": "wasm",
"callFunc": "increment_1",
"payments": 0
}
В тестовом конфиге будем использовать один этот контракт, который создаст десять счетчиков при параллелизме, равном восьми. Сравнивать транзакции 103, создание смарт-контракта, смысла не будет: здесь докер, очевидно, сильно отстанет за счет времени на создание контейнера. А у WASM это время будет сопоставимо с вызовом смарт-контракта. По этой причине, кстати, функциональность вызова одного смарт-контракта через другие актуальна только для WASM — представьте, сколько времени это будет занимать на сложных проектах с докером.
Вернемся к бенчмаркам. Посмотрим, сколько занимают вызовы смарт-контракта в докере:
![Каждое деление по оси ординат — 50 мс Каждое деление по оси ординат — 50 мс](https://habrastorage.org/getpro/habr/upload_files/279/17c/3f5/27917c3f5678895677cb1145156dfd6c.png)
Видно, что многие вызовы занимают больше 50, а один вообще занял 170 мс. Медиана — 30 мс.
А это прогон на WASM:
![В тестовом конфиге было 4 ядра, а параллелизм у нас 8. Поэтому распределение не слишком равномерно В тестовом конфиге было 4 ядра, а параллелизм у нас 8. Поэтому распределение не слишком равномерно](https://habrastorage.org/getpro/habr/upload_files/262/eb1/718/262eb1718428e230c4da4a3eea26e9fd.png)
Максимальное время на вызов у WASM — 2 мс. Медиана — 0,391 мс. Получается, что без сетевого оверхеда gRPC в докере производительность увеличилась в 76 раз.
Сейчас такой показатель возможен только в лабораторных условиях, по факту мы получаем разницу в 3–4 раза. Чтобы приблизиться к идеальным показателям в рабочих кейсах, в этом году мы займемся улучшением обработки и валидации блоков (наборов транзакций при их создании) — в итоге планируем ускориться как минимум в 30 раз. В перспективе производительность WASM будет упираться лишь в возможности сети.
Планы на WASM
Виртуальная машина WEVM стала главным функционалом недавнего обновления 1.14.0 open-source платформы Waves Enterprise. Вскоре мы добавим эту функциональность в приватную блокчейн-платформу «Конфидент». В будущих релизах планируем расширять возможности WEVM: реализовать локальное тестирование контрактов, чтобы WASM-контракты проверялись на виртуальной машине с симуляцией блокчейна. Так можно будет убедиться, что контракт собрался правильно и не использует ничего лишнего.
Будем расширять нашу стандартную библиотеку — набор функций, доступный байт-коду для работы с нодой — и дорабатывать утилиту cargo-we. Мы не хотим останавливаться только на Rust для написания смарт-контрактов, так что в будущем расширим поддержку языков: JS, TypeScript, AssemblyScript (который применяется в том числе для WebAssembly), а также Java, Kotlin, Scala.
Мы обязательно вернемся к теме WebAssembly в блоге и более подробно рассмотрим возможности нашего нового инструментария.