Summary
- Most programs support multiple discrete instruction handlers - you decide when writing your program what these instruction handlers are and what data must accompany them when writing your program what these instruction handlers are and what data
- Rust enums are often used to represent discrete program instruction
- You can use the
borshcrate and thederiveattribute to provide Borsh deserialization and serialization functionality to Rust structs - Rust
matchexpressions help create conditional code paths based on the provided instruction
Lesson
One of the most basic elements of a Nexis Native Chain program is the logic for handling instruction data. Most programs support multiple related functions and use differences in instruction data to determine which code path to execute. For example, two different data formats in the instruction data passed to the program may represent instructions for creating a new piece of data vs deleting the same piece of data. Since instruction data is provided to your program’s entry point as a byte array, it’s common to create a Rust data type to represent instructions in a way that’s more usable throughout your code. This lesson will walk through how to set up such a type, how to deserialize the instruction data into this format, and how to execute the proper code path based on the instruction passed into the program’s entry point.Rust basics
Before we dive into the specifics of a basic Nexis Native Chain program, let’s talk about the Rust basics we’ll be using throughout this lesson.Variables
Variable assignment in Rust happens with thelet keyword.
mut keyword. Defining a variable with
this keyword means that the value stored in it can change.
Structs
A struct, or structure, is a custom data type that lets you package together and name multiple related values that make up a meaningful group. Each piece of data in a struct can be of different types and each has a name associated with it. These pieces of data are called fields. They behave similarly to properties in other languages.Enumerations
Enumerations (or Enums) are a data struct that allow you to define a type by enumerating its possible variants. An example of an enum may look like:LightStatus enum has two possible variants in this situation: it’s either
On or Off.
You can also embed values into enum variants, similar to adding fields to a
struct.
On variant of LightStatus
requires also setting the value of color.
Match statements
Match statements are very similar toswitch statements in C/C++. The match
statement allows you to compare a value against a series of patterns and then
execute code based on which pattern matches the value. Patterns can be made of
literal values, variable names, wildcards, and more. The match statement must
include all possible scenarios, otherwise the code will not compile.
Implementations
Theimpl keyword is used in Rust to define a type’s implementations. Functions
and constants can both be defined in an implementation.
boo here can only be called on the type itself rather than an
instance of the type, like so:
answer requires a mutable instance of Example and can be called
with dot syntax:
Traits and attributes
You won’t be creating your own traits or attributes at this stage, so we won’t provide an in depth explanation of either. However, you will be using thederive attribute macro and some traits provided by the borsh crate, so it’s
important you have a high level understanding of each.
Traits describe an abstract interface that types can implement. If a trait
defines a function bark() and a type then adopts that trait, the type must
then implement the bark() function.
Attributes add
metadata to a type and can be used for many different purposes.
When you add the
derive attribute
to a type and provide one or more supported traits, code is generated under the
hood to automatically implement the traits for that type. We’ll provide a
concrete example of this shortly.
Representing instructions as a Rust data type
Now that we’ve covered the Rust basics, let’s apply them to Nexis Native Chain programs. More often than not, programs will have more than one function. For example, you may have a program that acts as the backend for a note-taking app. Assume this program accepts instructions for creating a new note, updating an existing note, and deleting an existing note. Since instructions have discrete types, they’re usually a great fit for an enum data type.NoteInstruction enum comes with embedded data
that will be used by the program to accomplish the tasks of creating, updating,
and deleting a note, respectively.
Deserialize instruction data
Instruction data is passed to the program as a byte array, so you need a way to deterministically convert that array into an instance of the instruction enum type. In previous units, we used Borsh for client-side serialization and deserialization. To use Borsh program-side, we use theborsh crate. This crate
provides traits for BorshDeserialize and BorshSerialize that you can apply
to your types using the derive attribute.
To make deserializing instruction data simple, you can create a struct
representing the data and use the derive attribute to apply the
BorshDeserialize trait to the struct. This implements the methods defined in
BorshDeserialize, including the try_from_slice method that we’ll be using to
deserialize the instruction data.
Remember, the struct itself needs to match the structure of the data in the byte
array.
unpack that
accepts the instruction data as an argument and returns the appropriate instance
of the enum with the deserialized data.
It’s standard practice to structure your program to expect the first byte (or
other fixed number of bytes) to be an identifier for which instruction the
program should run. This could be an integer or a string identifier. For this
example, we’ll use the first byte and map integers 0, 1, and 2 to instructions
create, update, and delete, respectively.
- This function starts by using the
split_firstfunction on theinputparameter to return a tuple. The first element,variant, is the first byte from the byte array and the second element,rest, is the rest of the byte array. - The function then uses the
try_from_slicemethod onNoteInstructionPayloadto deserialize the rest of the byte array into an instance ofNoteInstructionPayloadcalledpayload - Finally, the function uses a
matchstatement onvariantto create and return the appropriate enum instance using information frompayload
ok_or and unwrap functions are used for error handling and will be
discussed in detail in another lesson.
Program logic
With a way to deserialize instruction data into a custom Rust type, you can then use appropriate control flow to execute different code paths in your program based on which instruction is passed into your program’s entry point.match statement.
Program file structure
The Hello World lesson’s program was simple enough that it could be confined to one file. But as the complexity of a program grows, it’s important to maintain a project structure that remains readable and extensible. This involves encapsulating code into functions and data structures as we’ve done so far. But it also involves grouping related code into separate files. For example, a good portion of the code we’ve worked through so far has to do with defining and deserializing instructions. That code should live in its own file rather than be written in the same file as the entry point. By doing so, we would then have 2 files, one with the program entry point and the other with the instruction code:- lib.rs
- instruction.rs
lib.rs. You must register every file in your program like this.
use
statements in other files will need to be prefaced with the pub keyword:
Lab
For this lesson’s lab, we’ll be building out the first half of the Movie Review program that we worked with in Module 1. This program stores movie reviews submitted by users. For now, we’ll focus on deserializing the instruction data. The following lesson will focus on the second half of this program.1. Entry point
We’ll be using Nexis Native Chain Playground again to build out this program. Nexis Native Chain Playground saves state in your browser, so everything you did in the previous lesson may still be there. If it is, let’s clear everything out from the currentlib.rs file.
Inside lib.rs, we’re going to bring in the following crates and define where
we’d like our entry point to the program to be with the entrypoint macro.
2. Deserialize instruction data
Before we continue with the processor logic, we should define our supported instructions and implement our deserialization function. For readability, let’s create a new file calledinstruction.rs. Inside this
new file, add use statements for BorshDeserialize and ProgramError, then
create a MovieInstruction enum with an AddMovieReview variant. This variant
should have embedded values for title, rating, and description.
MovieReviewPayload struct. This will act as an intermediary
type for deserializtion so it should use the derive attribute macro to provide
a default implementation for the BorshDeserialize trait.
MovieInstruction enum that defines
and implements a function called unpack that takes a byte array as an argument
and returns a Result type. This function should:
- Use the
split_firstfunction to split the first byte of the array from the rest of the array - Deserialize the rest of the array into an instance of
MovieReviewPayload - Use a
matchstatement to return theAddMovieReviewvariant ofMovieInstructionif the first byte of the array was a 0 or return a program error otherwise
3. Program logic
With the instruction deserialization handled, we can return to thelib.rs file
to handle some of our program logic.
Remember, since we added code to a different file, we need to register it in the
lib.rs file using pub mod instruction;. Then we can add a use statement to
bring the MovieInstruction type into scope.
add_movie_review that takes as arguments
program_id, accounts, title, rating, and description. It should also
return an instance of ProgramResult Inside this function, let’s simply log our
values for now and we’ll revisit the rest of the implementation of the function
in the next lesson.
add_movie_review from process_instruction (the
function we set as our entry point). To pass all the required arguments to the
function, we’ll first need to call the unpack we created on
MovieInstruction, then use a match statement to ensure that the instruction
we’ve received is the AddMovieReview variant.
Challenge
For this lesson’s challenge, try replicating the Student Intro program from Module 1. Recall that we created a frontend application that lets students introduce themselves! The program takes a user’s name and a short message as theinstruction_data and creates an account to store the data onchain.
Using what you’ve learned in this lesson, build the Student Intro program to the
point where you can print the name and message provided by the user to the
program logs when the program is invoked.
You can test your program by building the
frontend
we created in the
Serialize Custom Instruction Data lesson and then
checking the program logs on Nexis Native Chain Explorer. Remember to replace the program ID
in the frontend code with the one you’ve deployed.
Try to do this independently if you can! But if you get stuck, feel free to
reference the solution code.