В последнее время многие блокчейн-платформы для исполнения смарт-контрактов переключились на WASM — WebAssembly. Мы не стали исключением, и в последнем обновлении тоже добавили WebAssembly как альтернативу привычному Docker. В этом посте мы расскажем, для каких задач нам потребовался именно WASM, что мы достигли с ним на сегодня и как WASM отражается на производительности блокчейна.

Что такое WebAssembly? Это переносимый бинарный формат для исполнения программ, известный с 2015 года. Изначально основной целью WASM — исполнять более компактный и быстрый код в вебе, в клиенте браузера. Разработчикам удалось ее достичь, но на этом WebAssembly не остановился. Сегодня он уже дорос до расширения WebAssembly System Interface: оно позволяет создавать виртуальные машины, вполне способные конкурировать с зарекомендовавшими себя решениями типа JVM, .NET, BEAM.

Для WebAssembly нашлось применение не только в браузерах, но и в блокчейне, для смарт-контрактов. WebAssembly Runtime Environments (RE) даже использует в названии Assembly, а байт-код здесь представляет собой некий набор инструкций, что роднит его с другими ассемблерами. Но фактически это стековая машина, где регистр не используется и мы оперируем четырьмя типами значений — i32, i64, f32, f64.

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

Базовая структура WebAssembly
Базовая структура WebAssembly

Для удобства чтения и редактирования в 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 для пакетного менеджера с утилитами для удобного создания и сборки проекта. В будущем мы хотим ее сильно расширять, и об этом я расскажу чуть позже.

Эту реализацию 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

Посмотрим, что внутри:

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 и получим байт-код контракта в бинарном виде:

Нам также нужно получить SHA-сумму. Так мы удостоверимся в том, что задеплоили именно то, что хотели задеплоить:

Вот в таком виде хранится контракт. По API получить его очень просто:

Вызовем транзакцию 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"
  }

Выполняем контракт, получаем в стейте заданные счетчики:

Теперь почти так же, как с докером, мы отправляем транзакцию 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 мс

Видно, что многие вызовы занимают больше 50, а один вообще занял 170 мс. Медиана — 30 мс.

А это прогон на WASM:

В тестовом конфиге было 4 ядра, а параллелизм у нас 8. Поэтому распределение не слишком равномерно
В тестовом конфиге было 4 ядра, а параллелизм у нас 8. Поэтому распределение не слишком равномерно

Максимальное время на вызов у 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 в блоге и более подробно рассмотрим возможности нашего нового инструментария.

Комментарии (2)


  1. domix32
    05.04.2024 10:27

    Планируется ли поддержка Moonbit?


    1. klauss_z
      05.04.2024 10:27

      При проектировании мы специально ориентировались на независимость от языков, поэтому реализовали свою минимальную стандартную библиотеку с учетом специфики платформы. Так что для разработки контрактов подходит любой язык, который может компилироваться в WASM. CDK в нашем случае — это просто удобная обертка и набор утилит. Без него можно написать контракт, но это будет чуть тяжелее.

      MoonBit старается быть полностью совместимым с WASM, поэтому теоретически он должен работать из коробки. Но так как он пока еще в beta, сложно сказать что-то более точно.