why is this necessary and how does it work

Recently, many blockchain platforms for executing smart contracts have switched to WASM – WebAssembly. We were no exception, and in the latest update we also added WebAssembly as an alternative to the usual Docker. In this post, we will tell you what tasks we needed WASM for, what we have achieved with it to date, and how WASM affects the performance of the blockchain.

What is WebAssembly? It is a portable binary format for program execution, known since 2015. Initially, the main goal of WASM is to execute more compact and faster code on the web, in the browser client. The developers managed to achieve it, but WebAssembly didn’t stop there. Today it has already grown to the WebAssembly System Interface extension: it allows you to create virtual machines that are quite capable of competing with proven solutions such as JVM, .NET, BEAM.

WebAssembly has found application not only in browsers, but also in the blockchain, for smart contracts. WebAssembly Runtime Environments (RE) even uses Assembly in its name, and the bytecode here is a certain set of instructions, which makes it similar to other assemblers. But in fact, this is a stack machine, where the register is not used and we operate with four types of values ​​- i32, i64, f32, f64.

More complex data types are handled by linear memory, which stores the necessary bytecode data. They are collected in the form of a conditional “heap”, which can grow to certain limits, based on the limitations of the bytecode itself and the virtual machine on which it is executed.

Basic WebAssembly structure

Basic WebAssembly structure

For ease of reading and editing, WebAssembly provides a text format. It is one big S-expression and has a Lisp-like syntax, reminiscent of Common Lisp and Scheme:

(module
  (func (param $lhs 132) (param $rhs 132) (result 132)
    local get $lhs
    local get $rhs
    132. add))

The text format allows us to read and, if necessary, edit bytecode, understanding which instructions are used and how they are used.

Why do we need WASM

There are several reasons why you should pay attention to WebAssembly in principle.

  • As a virtual machine for executing smart contracts, WASM has very high performance.

  • The bytecode itself is very small in size, which is important for the blockchain, since this way we can avoid inflating transactions and state.

  • Any runtime is in fact an isolated environment. In its basic form, WASM does not have access to anything outside: it is loaded with bytecode, which is then executed.

  • With WASM, you can achieve deterministic execution by simply eliminating the use of floating point values.

  • WASM supports the compilation of multiple languages, which allows you to develop smart contracts in any programming language.

These reasons overlapped with our own:

  • We wanted to increase productivity compared to current docker contracts. It was clear that this would work, since in Docker a lot of time is spent on network communication of contracts with the node, raising containers and much more.

  • We wanted to implement one smart contract calling the methods of another, but for the same reasons as in the previous paragraph, such calls do not seem realistic in Docker.

At the same time, it was important to prevent a strong increase in the size of the transaction. We will have to store the bytecode on-chain, so minimizing the bytecode has become a very priority: as few instructions as possible in the bytecode itself, as much delegation of instructions and heavy calculations as possible to a higher level.

Finally, it was important not to break what was already working. Therefore, in the end, we did not create new transactions, but updated existing ones, adding the necessary fields for WASM contracts. Inside the node, we also tried to preserve the entire current flow of contracts and simply added another execution engine there. Thus, it was possible to preserve all the developments of the Docker implementation.

Waves Enterprise Virtual Machine (WEVM)

The result of working with WASM was Waves Enterprise Virtual Machine – a new engine for executing smart contracts on our platform. It’s worth clarifying here: although the main product of our company is the Confident blockchain platform, the components common to the Waves Enterprise public blockchain protocol bear the original name of the open-source project.

WEVM includes several components:

  • the WASM interpreter, which executes the bytecode;

  • a set of functions for working with a node, which extends the interpreter and allows the bytecode to interact with the node, perform translations, create assets, get balance, etc.;

  • interface for interaction with the node;

  • a mechanism for managing the execution of smart contracts for the purpose of calling the methods of another by one contract.

During our research, we realized that almost all existing virtual machines and interpreters are developed in Rust, and we decided to also start developing WEVM with Rust. As an interpreter we used wasmi, developed by Parity. For the most part, it is used specifically for smart contracts; there is nothing superfluous in it. For communication between the virtual machine and the node, Java Native Interface (JNI) and crate jni v.0.21.0 are used.

In short, WEVM works as follows. A miner on the network launches a new virtual machine, similar to the implementation in Docker. The miner loads a transaction with bytecode from the UTX pool into it. The virtual machine returns a result, which is used by the miner as the result of executing the smart contract and, accordingly, the transaction.

Additionally, we have developed Rust CDK – a small eDSL that extends the language with a set of attributes, functions and much more for convenient and understandable writing of smart contracts in our platform:

#! [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);
}

The Rust CDK includes the cargo-we extension for the package manager with utilities for convenient project creation and assembly. In the future we want to expand it greatly, and I will talk about this a little later.

We compared this implementation of WASM with Docker in different benchmarks, with and without sharding, with MVCC enabled and disabled, etc. In the best run for Docker, network performance on average reached 50–60 tps – and this is with 300–400 tps for WASM . At the end of the post we will show the benchmarks of a specific smart contract.

Writing a smart contract for WASM

We talked about how to deploy an open source version of our platform in one of the previous posts (and in friend, more accessible). A CDK for smart contracts in Rust is available at github. If interested, you can repeat the smart contract, which I will write below as an example. And with the help of our cargo-we utility I’ll tell you about some of its features.

First, let's install this utility, it is publicly available:

cargo install --git https://github.com/waves-enterprise/we-cdk.git --force

In the future, we plan to publish the utility not only on GitHub, but also in the public Rust package repository. The utility provides initialization, project assembly and debugging in WASM and text format.

Let's create a new project called flipper:

cargo we new flipper

Let's see what's inside:

Cargo.toml is a regular manifest file, like package.json for JS and TypeScript. It includes the project name, version and all necessary settings. Since the CDK has not yet been published, we manually added in the last line that it needs to be installed from git:

[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" }

We also automatically create a gitignore and the contract itself – 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);
}

This uses basic Rust syntax, nothing specific; no_std means that we do not use the standard library – with it the binary will be too large.

We import our own library and into lib.rs Let's start writing the logic of the contract. Let's take a standard function as an example and add the action attribute to show that it is available externally when calling the contract:

#[action]
fn test() {
}

Let's add parameters. The CDK describes four standard data types used in our platform: integer, boolean, string and binary. Let's choose from this list:

#[action]
fn test(value: Integer) {
}

Functions available externally to call do not return any results: it is all neatly hidden in the CDK. Under the hood, the function returns an error code, and if there is none, it returns zero. So we don't need to use return values, we just need to describe the arguments.

The contract must contain the _constructor function, marked as action – it can be found in the base code lib.rs higher. This is what is called via CreateContract Transaction. Even if we have nothing to write in the constructor, we can leave it empty; then the function simply won't do anything. Next, we can describe any functions of the contract and use the set of functions available from the CDK to work with the node: reading and updating contract values, working with tokens.

The approach here is somewhat different from the approach of smart contracts in Docker. There, the contract receives an input transaction and performs an action depending on the specified parameters. Here, when using the CreateContract transaction, the constructor function is called. There can only be one; it describes some code that initializes the state of the contract. In other cases, a CallContract can be used specifying specific functions to call. This approach is more similar to the approach in Solidity.

Calling one smart contract to another

In CDK we have already implemented all the basic functions – creation, burning, re-issuance, leasing of tokens, etc. Separately, I want to show how to implement a call from one contract to another – this is exactly what is available in WASM and is not applicable in the docker implementation.

First we need to describe the functions that we will call. Let's go back to lib.rs and add a standard interface to it, like in Java. Let's mark it with the interface attribute:

#[interface]
trait i_contract {
  fn data_fn(value: Integer):
}

Next, we can call the function of another contract. This is done through the call_contract! macro: we specify the interface we want to use and the contract address (base58), then the function we want to call. And finally, we pass the arguments to it.

call_contract! {
  i_contract(base64! ("ADDRESS"))::data_fn(42)
}

You can attach a payment to the call_contract transaction. To do this, we use the system token constant SYSTEM_TOKEN. We also indicate that we want to make the payment together with the call. This is what the final flip function will look like:

#[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)
  }
}

The logic described at the beginning will be executed first. Then another contract will be called, which will perform the actions. And in case of an error, the function will complete its work with the error code that occurred during the execution of another contract. The flip function will be considered completed successfully if there are no errors – this is written in the CDK. The node will receive this data, provide it to the user, and he will make the decision himself.

Measuring WASM performance

For the benchmark we ran the following contract:

#! [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);
}

The contract code is simple, but quite indicative. Here there are two requests to the node: to receive the storage and to write the storage. The logic is simple: we take the shard (counter) number from the storage and rewrite it, increasing it by 1.

Let's call bulid and get the bytecode of the contract in binary form:

We also need to get the SHA amount. This way we can make sure that we deployed exactly what we wanted to deploy:

This is how the contract is stored. It's very easy to get it using the API:

Let's call transaction 103, creating a contract on the node. Everything is the same as when working with Docker, but instead of imageHash we use bytecode and 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"
  }

We fulfill the contract and receive the specified counters in the state:

Now almost the same as with docker, we dispatch transaction 104, calling the contract:

{
  “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
}

In the test config we will use this one contract, which will create ten counters with a parallelism of eight. There will be no point in comparing transactions 103, creating a smart contract: here the docker will obviously lag far behind due to the time it takes to create the container. And for WASM this time will be comparable to calling a smart contract. For this reason, by the way, the functionality of calling one smart contract through others is relevant only for WASM – imagine how long this will take on complex projects with Docker.

Let's return to benchmarks. Let's see how long smart contract calls take in Docker:

Each division along the ordinate axis is 50 ms

Each division along the ordinate axis is 50 ms

It can be seen that many calls take more than 50, and one took 170 ms. Median – 30 ms.

And this is a run on WASM:

The test config had 4 cores, but we have 8 parallelism. Therefore, the distribution is not very uniform

The test config had 4 cores, but we have 8 parallelism. Therefore, the distribution is not very uniform

The maximum call time for WASM is 2 ms. Median – 0.391 ms. It turns out that without the gRPC network overhead in Docker, performance increased by 76 times.

Now such an indicator is only possible in laboratory conditions; in fact, we get a difference of 3–4 times. To get closer to ideal performance in work cases, this year we will improve processing and validation blocks (sets of transactions when creating them) – in the end we plan to speed up at least 30 times. In the future, the performance of WASM will be limited only by the capabilities of the network.

Plans for WASM

The WEVM virtual machine has become the main functionality of the recent updates 1.14.0 open-source Waves Enterprise platform. We will soon add this functionality to the private blockchain platform Confident. In future releases we plan to expand the capabilities of WEVM: implement local contract testing so that WASM contracts are tested on a virtual machine with a blockchain simulation. This way you can make sure that the contract is assembled correctly and does not use anything unnecessary.

We will expand our standard library – a set of functions available to bytecode for working with a node – and improve the cargo-we utility. We don’t want to stop only at Rust for writing smart contracts, so in the future we will expand support for languages: JS, TypeScript, AssemblyScript (which is also used for WebAssembly), as well as Java, Kotlin, Scala.

We will definitely return to the topic of WebAssembly in the blog and take a closer look at the capabilities of our new toolkit.

Similar Posts

Leave a Reply

Your email address will not be published. Required fields are marked *