In this blog post, we will demonstrate how to create a Wasm kernel running on a Tezos Smart Optimistic Rollup (abbreviated SORU). To do so, we are going to create a counter in Rust, compile it to WebAssembly (abbreviated Wasm), and simulate its execution.
Prerequisites 🦀
To develop your own kernel you can choose any language you want that can compile to Wasm.
A SDK is being developed in Rust by Tezos core dev teams, so we will use Rust as the programming language. For installation of Rust, please read this document.
For Unix system, Rust can be installed as follow:
Create your project 🚀
Let’s initialize the project with cargo.
As you noticed, we are using the --lib option, because we don’t want to have the default main function used by Rust. Instead we will pass a function to a macro named kernel_entry.
The file Cargo.toml (aka “manifest”) contains the project’s configuration.
Before starting your project you will need to update the lib section to allow compilation to Wasm. And you will also need to add the kernel library as a dependency. To do so you will have to update your Cargo.toml file as described below:
To compile your kernel to Wasm, you will need to add a new target to the Rust compiler. The wasm32-unknown-unknown target.
The project is now set up. You can build it with the following command:
The Wasm binary file will be located under the directory target/wasm32-unknown-unknown/release
Let’s code 💻
Rust code lives in the src directory. The cargo init --lib has created for you a file src/lib.rs.
Hello Kernel
As a first step let’s write a hello world kernel. The goal of it is simple: print “Hello Kernel” every time the kernel is called.
We are importing two crates of the SDK, the host and the entry point one. The host crate aims to provide hosts function to the kernel as safe Rust. The entry point crate exposes a macro to run your kernel.
The main function of your kernel is the function given to the macro kernel_entry. The host argument allows you to communicate with the kernel. It gives you the ability to:
- read inputs from the inbox
- write debug messages
- reveal data from the reveal channel
- read and write data from the durable storage
- etc.
This function is called one time per Tezos block and will process the whole inbox.
Let me explain the different vocabulary used in kernel development:
- The inbox is the list of messages which will be processed by the entry function.
- The reveal channel is used to read data too big to fit in an inbox message (4kb).
- The durable storage is a kind of filesystem, you are able to write, read, move, delete, copy files.
Looping over the inbox
Supposing our user has sent a message to the rollup, we need to process it. To do so, we have to loop over the inbox.
As explained earlier, the host argument gives you a way to read the input from the inbox with the following expression:
It may happen the function fails, in which case the error should be handled. In our case, to make it simple we won’t handle this error.
Then if it succeed, the function returns an optional. Indeed, it is possible that the inbox is empty and in this case there are no more messages to read.
Let’s write a recursive function to print “Hello message” for each input.
Do not forget to call your function:
The read messages are simple binary representation of the content sent by the user. To process them you will have to deserialize them from binary.
And that's not all, in the inbox, there are more than messages from your user. The inbox is always populated with 3 messages: Start of Level, Info per Level, End of Level, these are called the "internal messages".
Thankfully it's easy to differentiate the internal messages from the external messages. The rollup internal messages start with the byte 0x00 and the external messages start with the byte 0x01.
Let's ignore the messages from the rollup and get the appropriate bytes sent by our user (the external messages):
Let’s write some Rust
In the previous section, we have set up the boiler plate to read inputs from the inbox.
Now we can concentrate on writing a normal rust program which will be our counter.
To do so, let’s create another file src/counter.rs.
To allow feedback from the compiler when developing, just add the counter as a module at the top of the src/lib.rs file:
Let’s define a new struct which will act as our rollup durable storage:
The default value of our counter will be zero. Rust comes with a convention for default value, you have to implement the Default trait.
Let’s implement some primitive functions for our counter to increment or decrement it.
Let’s say the user can increment/decrement/reset the counter. A good way to represent these possibilities is to use an enum type. This enum will be the possible action/messages sent by our user.
And now we just need to define a function that compute the transition of the state with a given action.
As you see, this file is not related to the kernel part. Which means you can test your kernel as you would do for normal Rust program.
Serialization and deserializarion
To serialize/deserialize the messages from the user you can use the provided built in serialize and deserialize of the Rust library.
To make it simple, let’s assume the following encoding:
- 0x00 for Increment
- 0x01 for Decrement
- 0x02 for Reset
To convert a type to another Rust comes with a trait, the TryFrom one.
Let’s implement it for the UserAction
Because we want to save the state of our rollup between two kernel calls, we need to store it in the durable storage, where it will be represented as array of bytes.
So let’s implement the same trait for the Counter.
And let’s implement the opposite, counter into bytes.
Let’s glue everything
The last step is to glue everything:
1. deserialize the state
2. deserialize the message
3. compute the new state
4. serialize the state
5. save it
Let’s start by deserializing the state of your rollup.
Read the state
The first step of your entry function will be to read the durable state of your rollup.
Because the durable storage acts as a filesystem you will need to define a path to this file.
Then you can read your file, and deserialize it to a Counter type.
If the file does not exist, let’s say we want to use the default value of our Counter, that’s how we will initiate the state of our counter.
Compute the new state
Let’s modify the execute signature to take as a parameter a counter and return a new one.
Don’t forget to update the call of the execute function:
Save the state
It’s the same as read the state, instead that the function is named store_write
Your entry function should look like this:
That’s it
That’s it, you have developed a kernel in Rust, you can compile it to wasm and deploy your rollup following the Tezos guide.
Bonus ✨
Are we sure the kernel is working? 🤔
Sure, you can write unit tests as you would do in rust. But there is a tool to simulate your kernel execution.
The Tezos core dev has developed a binary octez-smart-rollup-debugger to simulate the execution of your kernel.
Let’s dive into this tool.
How to install it?
To install it, you will need to build Tezos from source (master branch). And then a binary should have appeared in your Tezos repository. If the Tezos repository is in your PATH you can use the octez-smart-rollup-wasm-debugger without specifying the path to it.
Before using this command, let’s define by hand our inbox in a JSON file:
Let’s say we want to increment 4 times our counter, and decrement it one time:
There are two kind of messages, internal and external one. The internal messages are sent by smart contract to the rollup or added by the VM running the kernel (Start of Level, Info per Level, End of Level). The external messages are sent by users. In this example, we want to simulate messages from users.
Now that you have an inputs.json file, let’s simulate our kernel.
As you can see a primitive shell has started, and it’s waiting for some inputs, commands, to simulate your kernel.
First, let’s load your inputs:
To be sure your inputs have been loaded, you can print the inbox:
As you see, there are 8 messages, the Start_of_Level, the Info_per_Level, the 5 messages of the user, and the End _of_level.
Then let’s compute the whole inbox.
Here you can see the number of ticks the evaluation took. This relates to the number of atomic operations the VM needed to do to execute the kernel. This number is actually bounded by the protocol, and the reason why a kernel can ask to be executed multiple time per level, but this is out of the scope of this tutorial. For now, just remember that it is a measure of the length of the execution.
And then you can check if the state of your kernel has been saved
It should return you the value 3 encoded with 8 bytes (because the counter is an int64)
The debugger is still a work in progress tool which will be greatly improved in the future. In this blog post we only discovered a few commands of it but it can achieve a lot more.
Voilà 🎉
In this post, we discovered how to write a kernel, how to process user inputs, how to persist a state in the kernel durable storage.
In a next tutorial, we will see how to deploy a kernel larger than 24kb. And in another tutorial, we will directly post the result of the counter in a smart contract on layer 1.
If you want to know more about Marigold, please follow us on social media (Twitter, Reddit, Linkedin)!