#rollups

By Pierre-Louis Dubois

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:


curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
This blog post was tested with Rust 1.67

Create your project 🚀

Let’s initialize the project with cargo.


$ mkdir counter-kernel
$ cd counter-kernel
$ cargo init --lib

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:


# Cargo.toml 

[package]
name = "counter-kernel"
version = "0.1.0"
edition = "2021"

[lib]
crate-type = ["cdylib", "lib"]

[dependencies]
tezos-smart-rollup = "0.2.1"
We won't explain in this article how to write tests with the mock_runtime and the mock_host which allow you to mock different parts of your kernel. But we need to include these libraries to compile the kernel.

To compile your kernel to Wasm, you will need to add a new target to the Rust compiler. The wasm32-unknown-unknown target.


rustup target add wasm32-unknown-unknown

The project is now set up. You can build it with the following command:


cargo build --release --target wasm32-unknown-unknown

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.


// src/lib.rs
use tezos_smart_rollup::{kernel_entry, prelude::Runtime};

// src/lib.rs
pub fn entry<Host: Runtime>(host: &mut Host) {
    host.write_debug("Hello Kernel\n");
}

kernel_entry!(entry);

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:


let input: Result<Option<Message>, RuntimeError> = host.read_input();

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.


// src/lib.rs

fn execute<Host: Runtime>(host: &mut Host) {
    // Read the input
    let input = host.read_input();

    match input {
        // If it's an error or no messages then does nothing
        Err(_) | Ok(None) => {}
        Ok(Some(_message)) => {
            // If there is a message let's process it.
            host.write_debug("Hello message\n");

            // Process next input
            execute(host);
        }
    }
}

Do not forget to call your function:


// src/lib.rs

pub fn entry<Host: Runtime>(host: &mut Host) {
    host.write_debug("Hello Kernel\n");
    execute(host)
}

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):


/ src/lib.rs

fn execute<Host: Runtime>(host: &mut Host) {
    // Read the input
    let input = host.read_input();

    match input {
        // If it's an error or no messages then does nothing
        Err(_) | Ok(None) => {}
        Ok(Some(message)) => {
            // If there is a message let's process it.
            host.write_debug("Hello message\n");
            let data = message.as_ref();
            // As in Ocaml, in Rust you can use pattern matching to easily match bytes.
            match data {
                [0x00, ..] => {
                    host.write_debug("Internal messages.\n");
                    execute(host)
                }
                [0x01, _user_message @ ..] => {
                    host.write_debug("External messages.\n");
                    execute(host)
                }
                _ => execute(host),
            }
        }
    }
}

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:


// src/lib.rs
mod counter;
use counter::*;

Let’s define a new struct which will act as our rollup durable storage:


// src/counter.rs

pub struct Counter {
    counter: i64
}

The default value of our counter will be zero. Rust comes with a convention for default value, you have to implement the Default trait.

If you are new to Rust, you can see trait as interface or module type

// src/counter.rs

impl Default for Counter {
    fn default() -> Counter {
        Counter {
            counter: 0
        }
    }
}

Let’s implement some primitive functions for our counter to increment or decrement it.


// src/counter.rs

impl Counter {
    fn increment(self) -> Counter {
        Counter {
            counter: self.counter + 1,
        }
    }

    fn decrement(self) -> Counter {
        Counter {
            counter: self.counter - 1,
        }
    }
}

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.


// src/counter.rs

pub enum UserAction {
    Increment,
    Decrement,
    Reset
}

And now we just need to define a function that compute the transition of the state with a given action.


// src/counter.rs

pub fn transition(counter: Counter, action: UserAction) -> Counter {
    match action {
        UserAction::Increment => counter.increment(),
        UserAction::Decrement => counter.decrement(),
        UserAction::Reset => Counter::default(),
    }
}

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


// src/counter.rs

impl TryFrom<Vec<u8>> for UserAction {
    type Error = String;

    fn try_from(value: Vec<u8>) -> Result<Self, Self::Error> {
        match value.as_slice() {
            [0x00] => Ok(UserAction::Increment),
            [0x01] => Ok(UserAction::Decrement),
            [0x02] => Ok(UserAction::Reset),
            _ => Err("Deserialization is not respected".to_string()),
        }
    }
}

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.


// src/counter.rs

impl TryFrom<Vec<u8>> for Counter {
    type Error = String;

    fn try_from(value: Vec<u8>) -> Result<Self, Self::Error> {
        value
            .try_into()
            .map_err(|_| "i64 is represented by 8 bytes".to_string())
            .map(i64::from_be_bytes)
            .map(|counter| Counter { counter })
    }
}

And let’s implement the opposite, counter into bytes.


// src/counter.rs

impl Into<[u8; 8]> for Counter {
    fn into(self) -> [u8; 8] {
        self.counter.to_be_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.


// src/lib.rs
// In your imports:
use tezos_smart_rollup::storage::path::OwnedPath;

// At the beginning of the entry function
let counter_path: OwnedPath = "/counter".as_bytes().to_vec().try_into().unwrap();

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.


// src/lib.rs
// Just below the above code
let counter = Runtime::store_read(host, &counter_path, 0, 8)
    .map_err(|_| "Runtime error".to_string())
    .and_then(Counter::try_from)
    .unwrap_or_default();
In the case of a RunTimeError we will use the default value of the counter. It’s not the best thing to do. A better solution would be using the Runtime::store_has function which checks if a file already exists or not.

Compute the new state

Let’s modify the execute signature to take as a parameter a counter and return a new one.


// src/lib.rs

fn execute<Host: Runtime>(host: &mut Host, counter: Counter) -> Counter {
    // Read the input
    let input = host.read_input();

    match input {
        // If it's an error or no message then does nothing}
        Err(_) | Ok(None) => counter,
        Ok(Some(message)) => {
            // If there is a message let's process it.
            host.write_debug("Hello message\n");
            let data = message.as_ref();
            match data {
                [0x00, ..] => {
                    host.write_debug("Message from the kernel.\n");
                    execute(host, counter)
                }
                [0x01, user_message @ ..] => {
                    host.write_debug("Message from the user.\n");
                    // We are parsing the message from the user.
                    // In the case of a good encoding we can process it.
                    let user_message = UserAction::try_from(user_message.to_vec());
                    match user_message {
                        Ok(user_message) => {
                            let counter = transition(counter, user_message);
                            execute(host, counter)
                        }
                        Err(_) => execute(host, counter),
                    }
                }
                _ => execute(host, counter),
            }
        }
    }
}

Don’t forget to update the call of the execute function:


// src/lib.rs
let counter = execute(host, counter);

Save the state

It’s the same as read the state, instead that the function is named store_write


/ src/lib.rs
// Below the execute function call
let counter: [u8; 8] = counter.into();
let _ = Runtime::store_write(host, &counter_path, &counter, 0);

Your entry function should look like this:


pub fn entry<Host: Runtime>(host: &mut Host) {
    let counter_path: OwnedPath = "/counter".as_bytes().to_vec().try_into().unwrap();
    let counter = host.store_read(&counter_path, 0, 8)
        .map_err(|_| "Runtime error".to_string())
        .and_then(Counter::try_from)
        .unwrap_or_default();

    host.write_debug("Hello kernel\n");
    let counter = execute(host, counter);

    let counter: [u8; 8] = counter.into();
    let _ = host.store_write(&counter_path, &counter, 0);
}

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.

If you want to deploy your kernel, make sure it’s less than ~24kb, more precisely your kernel origination operation should be as big as a Tezos operation (32kb). If not you can use wasm-strip to reduce the size of your kernel, the size of our counter is 23kb so you can deploy it.

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:

Remember the binary format we chose to represent the user action? 0x00 for Increment; 0x01 for Decrement; 0x02 for Reset.

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.


[
	[
		{
			"external": "00"
		},
		{
			"external": "00"
		},
		{
			"external": "00"
		},
		{
			"external": "00"
		},
		{
			"external": "01"
		}
	]
]

Now that you have an inputs.json file, let’s simulate our kernel.

Don’t forget to build your kernel to be sure you have the last version of it: cargo build --release --target wasm32-unknown-unknown

$ octez-smart-rollup-wasm-debugger target/wasm32-unknown-unknown/release/counter_kernel.wasm --inputs inputs.json 

As you can see a primitive shell has started, and it’s waiting for some inputs, commands, to simulate your kernel.

In the following commands, the first line represent the command, and the lines below represent the output of the command

First, let’s load your inputs:


load inputs
> Loaded 5 inputs at level 0

To be sure your inputs have been loaded, you can print the inbox:


show inbox
> 
{ raw_level: 0;
  counter: 0
  payload: Start_of_level }
{ raw_level: 0;
  counter: 1
  payload: Info_per_level {predecessor_timestamp = 1970-01-01T00:00:00-00:00; predecessor = BKiHLREqU3JkXfzEDYAkmmfX48gBDtYhMrpA98s7Aq4SzbUAB6M} }
{ raw_level: 0;
  counter: 2
  payload: 00 }
{ raw_level: 0;
  counter: 3
  payload: 00 }
{ raw_level: 0;
  counter: 4
  payload: 00 }
{ raw_level: 0;
  counter: 5
  payload: 00 }
{ raw_level: 0;
  counter: 6
  payload: 01 }
{ raw_level: 0;
  counter: 7
  payload: End_of_level }

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.


step result
> Evaluation took 134497 ticks so far
Status: Evaluating
Internal_status: Evaluation succeeded

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


show key /counter

It should return you the value 3 encoded with 8 bytes (because the counter is an int64)


0000000000000003

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)!

Scroll to top