#rollups

By Pierre-Louis Dubois

Tzwitter is the first non trivial kernel developed inside the kernel gallery. The goal of this kernel was to explore what kind of kernel can be implemented, how to manage assets, what issues have to be resolved when developing a kernel, and what can be improved in the current SDK.

This blog post aims to introduce the Tzwitter Smart Rollup, what are the implemented features and what were the different challenges during the development.

Tzwitter

A demonstration is always better than any explanations, thanks to the infra team of Marigold, the Tzwitter Smart Rollup has been originated on Ghostnet.

You can access the application via this link.

Some basic features of Twitter have been implemented: you can publish a tweet and like a tweet of someone. That’s pretty straight forward.

And because we wanted to explore assets management on Smart Rollups, we decided to represent a tweet as a token. So you can transfer a tweet to someone, or withdraw your tweet as a FA2 token on the Layer 1.

Also, don’t be surprised if your tweet takes time to appear, your operation has to be included in the Layer 1.

The Shared Inbox

The shared inbox is common to all Smart Rollups, the shared inbox acts as a transport layer (like TCP). So it means that your rollup should have a way to recognize what messages it should process.

There are many solutions to do so:

       - magic byte
       - framing protocol

Let’s see what are the advantages and the disadvantages of these solutions.

Magic Byte

The easiest solution would be to use a magic byte. When your rollup receives a message, it can check the second byte to check if it matches the defined magic byte (the first one is a byte that indicates if it is an internal or an external message). Then to send a message, all your users will have to prefix this magic byte to their payload.

The Tzwitter kernel is using this solution:


const MAGIC_BYTE: u8 = 0x42;

fn read_input_with_magic_byte<H: Runtime>(host: &mut H) -> Result<Option<Message>, RuntimeError> {
    loop {
        let input = host.read_input()?;
        match input {
            None => return Ok(None),
            Some(msg) => match msg.as_ref() {
                [0x00, ..] => return Ok(Some(msg)), // Any Internal Messages are accepted
                [0x01, MAGIC_BYTE, ..] => return Ok(Some(msg)), // The magic byte is matched, the message is kept
                _ => {} // in any other cases, messages are ignored
            },
        }
    }
}

Framing Protocol

Magic byte is not a convenient solution, one byte only represents 256 values. What we can do is to implement a new protocol named: the Framing Protocol.

The idea is simple, the first byte is the version of the protocol, the next 20 bytes are your rollup address, and the remaining bytes represent your payload.


fn read_input_with_framing_protocol<H: Runtime> (
    host: &mut H,
) -> Result<Option<Message>, RuntimeError> {
    // Get the rollup address
    let RollupMetadata {
        raw_rollup_address, ..
    } = host.reveal_metadata();

    loop {
        let input = host.read_input()?;
        match input {
            None => return Ok(None),
            Some(msg) => match msg.as_ref() {
                [0x00, ..] => return Ok(Some(msg)), // Any Internal Messages are accepted
                [0x01, remaining @ ..] => {
                    // The first byte of the framing protocol is 0x00: it indicates the version of the framing protocol
                    // The 20 next bytes are the targeted rollup address
                    // And the remaining bytes are the the payload of the user
                    // The function `inbox::ExternalMessageFrame::parse` is parsing everything for you
                    let Ok(ExternalMessageFrame::Targetted { address, .. }) =
                        inbox::ExternalMessageFrame::parse(remaining) else {continue};
                    // If the target rollup is the current one, then the message is returned
                    if address.hash().as_ref() == &raw_rollup_address {
                        return Ok(Some(msg));
                    }
                }
                _ => {}
            },
        }
    }
}

Managing assets on a Smart Rollup

As told before, each tweet is represented as a token in the Smart Rollup, the tweet can be transferred to someone or even withdrawn to the Layer 1.

At the date of this blog post, specifications or Tzip don’t exist yet to represent assets on the Smart Rollup. So if we want to manage assets we have to be creative or we can try to draw inspiration from the FA2 Tzip of the Layer 1.

The first step is to implement a ledger in your rollup.  For a Michelson Smart Contract the appropriate type would be:


big_map %ledger (pair address nat) nat

In a Smart Rollup, to represent a ledger we can use the durable storage to represent at the following path the amount of token identified by {id} owned by a given {address} :


/ledger/{address}-{id}/amount

Then you will have to implement the transfer function that manipulates the durable storage.


/// Read the token amount of a given address for a given token in the durable storage
///
/// Returns the u32 value stored under /ledger/{address}-{token-id}/amount
/// Returns 0 if the value does exist
fn read_amount<H: Runtime> (
    host: &mut H,
    address: &str,
    token_id: u32,
) -> Result<u32, &'static str> {
    let token_path = format!("/ledger/{}-{}/amount", &address, &token_id);
    let token_path = RefPath::assert_from(token_path.as_bytes());

    host.store_read(&token_path, 0, 8)
        .unwrap_or_default()
        .try_into()
        .map(u32::from_be_bytes)
        .map_err(|_| "deserialization error")
}

/// Write the token amount of a given address for a given token in the durable storage
///
/// Write the amount value under the path ledger/{address}-{token-id}/amount
fn write_amount<H: Runtime> (
    host: &mut H,
    address: &str,
    token_id: u32,
    amount: u32,
) -> Result< (), &'static str> {
    let token_path = format!("/ledger/{}-{}/amount", &address, &token_id);
    let token_path = RefPath::assert_from(token_path.as_bytes());

    host.store_write(&token_path, &amount.to_be_bytes(), 0)
        .map_err(|_| "runtime error")
}

/// Transfer a given token amount from the sender address to the receiver address
///
/// If the sender has to enough token, the transfer is aborted
fn transfer<H: Runtime> (
    host: &mut H,
    sender_address: &str,
    receiver_address: &str,
    token_id: u32,
    amount: u32,
) -> Result< (), &'static str> {
    // Get the token amount of the sender
    let sender_amount = read_amount(host, receiver_address, token_id)?;

    // Get the token amount of the receiver
    let receiver_amount = read_amount(host, sender_address, token_id)?;

    // check if the sender has enough token
    if sender_amount < amount {
        return Err("not enough amount");
    }

    // transfer the token amount from one address to the other
    let sender_amount = sender_amount - amount;
    let receiver_amount = receiver_amount + amount;

    // update the durable storage
    write_amount(host, sender_address, token_id, sender_amount)?;
    write_amount(host, receiver_address, token_id, receiver_amount)?;

    Ok(())
}

As you noticed, it’s very different than a Smart Contract, you have to think about the serialization and deserialization of your data, you also have to manage how to handle errors, etc…

Outbox Messages

In the Tzwitter kernel we also wanted to explore the withdrawing to the Layer 1. Maybe your user will want to withdraw assets, tickets or other tokens into the Layer 1.

To do so, you have to write a Smart Contract transaction into the outbox. Then the rollup will commit this outbox to the Layer 1 and when the commit will be cemented, anyone will be able to execute this transaction to your smart contract as described here.


fn send_operation_to_layer_one<H: Runtime> (host: &mut H) -> Result< (), &'static str> {
    // Michelson parameter of your smart contract
    let parameters = MichelsonPair(
        MichelsonString("Hello Smart contract".to_string()),
        MichelsonUnit,
    );

    // Smart contract address
    let destination = Contract::from_b58check("KT1TxqZ8QtKvLu3V3JH7Gx58n7Co8pgtpQU5")
        .map_err(|_| "Not a contract address")?;

    // Entrypoint address
    let entrypoint = Entrypoint::try_from("your-entrypoint".to_string())
        .map_err(|_| "Not a valid entrypoint")?;

    // Smart contract transaction
    let transaction = OutboxMessageTransaction {
        parameters,
        destination,
        entrypoint,
    };

    // Serialization of the transaction
    let batch = OutboxMessageTransactionBatch::from(vec![transaction]);
    let message = OutboxMessage::AtomicTransactionBatch(batch);
    let mut output = Vec::default();
    message
        .bin_write(&mut output)
        .map_err(|_| "serialization error")?;

    // Writing the serialized transaction to the outbox
    host.write_output(&output)
        .map_err(|_| "writing to outbox failed")?;

    Ok(())
}

Notice that the rust package tezos_data_encoding 0.4.0 is required to serialize data towards the outbox as this data is read by a L1 smart contract.

If you want to withdraw your assets to the Layer 1 (instead of sending a “hello world” string). You should implement a “mint” entry point with some constraint, and send the appropriate payload to it: token metadata, rollup address, etc…

Conclusion

When developing kernel, thinking about I/O is an important part of your kernel implementation. In this blog post you learned how to have messages targeting a specific rollup, how to represent and manipulate data in the durable storage, and how to send a message to a Layer 1 Michelson Smart Contract.

I hope this blog post will help you write your kernel, now it’s your turn to hack!

If you want to know more about Marigold, please follow us on social media (Twitter, Reddit, Linkedin)!

Scroll to top