How to write a Smart Rollup kernel

By Pierre-Louis Dubois

In this blog post, we will demonstrate how to create a Wasm kernel running on a Tezos Smart Optimistic Rollup. To do so, we are going to create a counter in Rust, compile it to WebAssembly (abbreviated Wasm), and simulate its execution.

Prerequisites :crab:

To develop your own kernel you can choose any language you want that can compile to Wasm.
A SDK is being developped 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.66

Create your project :rocket:

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]
host = { git = "https://gitlab.com/tezos/kernel/" }
debug = { git = "https://gitlab.com/tezos/kernel/" }
kernel = {git = "https://gitlab.com/tezos/kernel/"}
mock_runtime = { git = "https://gitlab.com/tezos/kernel/" }
mock_host = { git = "https://gitlab.com/tezos/kernel/" }

We won’t explain in this article how to write tests with the mock_runtim 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 :computer:

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 host::{rollup_core::RawRollupCore, runtime::Runtime};
use kernel::kernel_entry;

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

kernel_entry!(entry);

We are importing two crates of the SDK, the host and the kernel one. The host crate aims to provide hosts function to the kernel as safe Rust. The kernel 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 developement:

  • 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(MAX_INPUT_MESSAGE_SIZE);

The size of a layer 1 message is 4096 bytes. The kernel library has defined a constant to represent this value: MAX_INPUT_MESSAGE_SIZE.

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

use host::rollup_core::MAX_INPUT_MESSAGE_SIZE;

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

    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.
            println!("Hello message\n");

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

Do not forget to call your function:

// src/lib.rs

fn entry<Host: RawRollupCore>(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.

Thankfully it’s easy to differentiate the rollup messages from the user messages.
The rollup messages start with the byte 0x00 and the user messages start with the byte 0x01.

Let’s ignore the messages from the rollup and get the appropriate bytes sent by our user:

// src/lib.rs

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

    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("Message from the kernel.\n");
                    execute(host)
                },
                [0x01, ..] => {
                    host.write_debug("Message from the user.\n");
                    // Let's skip the first byte of the data to get what the user has sent.
                    let _user_message: Vec<&u8> = data.iter().skip(1).collect();
                    execute(host)
                }
                _ => execute(host)
            }            
        }
    }
}

If you want to learn more about this tutorial, please read our blog post on Marigold website :point_right: How to write a rollup kernel

3 Likes