Creating a smart contract in Rust

This tutorial will give an introduction on how to create a basic smart contract written in Rust for deployment to the Casper blockchain.

Throughout this tutorial series it will be shown how to develop a simple web based game that will interact with the Casper blockchain. The game will be written in JavaScript and the Casper blockchain is used to store the players high score for the game. A contract is created that will be able to store and retrieve the high-score from the blockchain.

The final game can be seen here:

Full source code can be found here:

https://github.com/playcasper/snake-casper-game

Pre-requisites:

  • Have a Linux development environment setup which includes Cmake, Cargo and Rust
  • Have some programming knowledge and be familiar with Rust.

These links can be helpful in getting things set up and learning more about the Rust programming language.

https://www.rust-lang.org/

What is a smart contract?

A smart contract is a self-contained program installed on a blockchain. In this case, the contract is installed on the Casper Network. A smart contract can interact with user accounts on the block chain and other contracts through entry points and allows for various triggers, conditions and logic.

Smart contracts can be written in any language that can compile down to WASM binaries, which stands for Web Assembly. The contract in this tutorial uses the Rust programming language.

Creating a smart contract

The first step is to fire up the Linux development environment.

Open up a terminal and change directory to the location where the project is to be created and run the following command:

cargo new highscore

This will automagically create a project with the main.rs file.

Now open the file and remove the pre-created content so new code can be added.
Add the following lines:

#![no_main]
#![no_std]

These attributes tell the program not to use the standard main function as its entry point and not to import the standard libraries.

Next, add the following code to define the crates that will be used, the entry points for the contract and the definition for the variables where the high scores will be stored.

This line ensures that the contract is compiled with the WASM option. If not, then a compile error will result.

#[cfg(not(target_arch = "wasm32"))]
compile_error!("target arch should be wasm32: compile with '--target wasm32-unknown-unknown'");

The contract stores the overall high score for the game but also it needs to store the highest score for each user. A vector is used which has the name as a key and the high score as the value. The Casper public key could also be used for the name which would be unique for each user.

This part defines a couple of the crates that are going to be used and the necessary calls.

extern crate alloc;
use alloc::string::String;
use alloc::vec;
use core::convert::TryInto;
use alloc::collections::BTreeMap;

use casper_contract::{
    contract_api::{runtime, storage},
    unwrap_or_revert::UnwrapOrRevert,
};

use casper_types::{
    api_error::ApiError,
    contracts::{EntryPoint, EntryPointAccess, EntryPointType, EntryPoints},
    CLType, CLValue, Key, URef, Parameter, 
};

This code defines the name of the overall high score and also the name of the person with the overall highest score. Then the two entry points are defined. One to set the score and one to get the score.

// Name of keys to store highest overall score and name
const HIGHSCORE_KEY: &str = "highest_score";
const HIGHSCORE_USER_KEY: &str = "highest_score_user";

// Names for entry points
const HIGHSCORE_SET: &str = "highscore_set";
const HIGHSCORE_GET: &str = "highscore_get";

Finally, the contract is given a name.

// Contract name
const CONTRACT_KEY: &str = "highscore";

Next the entry point functions are implemented starting with the function to set the score. The line #[no_mangle] is used to ensure that the system does not change critical syntax within the method names.

#[no_mangle]
pub extern "C" fn highscore_set() {

The functions arguments are the name of the player and the score, which need to be retrieved. Next, the current highest stored score is retrieved from the blockchain.  This syntax is used to catch any problems with the retrieved value, for example if a value does not exist.

// Retrieve the values from the user
    let key: String = runtime::get_named_arg("name");    
    let value: i32 = runtime::get_named_arg("value");

    // Retrieve the current highest score
    let score_uref: URef = runtime::get_key(HIGHSCORE_KEY)
        .unwrap_or_revert_with(ApiError::MissingKey)
        .into_uref()
        .unwrap_or_revert_with(ApiError::UnexpectedKeyVariant);
    let highest_score: i32 = storage::read(score_uref)
        .unwrap_or_revert_with(ApiError::Read)
        .unwrap_or_revert_with(ApiError::ValueNotFound);

The user’s score is then compared with the highest score. If the score is higher, then the stored highest score is updated with the new value and the name of the person who achieved the score.

    // Compare the new score with the highest score
    if value > highest_score {
        // We have a new highest score so update the score
        storage::write(score_uref, value);

        // Update the highscorer's name
        let user_uref: URef = runtime::get_key(HIGHSCORE_USER_KEY)
            .unwrap_or_revert_with(ApiError::MissingKey)
            .into_uref()
            .unwrap_or_revert_with(ApiError::UnexpectedKeyVariant);

        storage::write(user_uref, key.as_str());
    }

The current users score is then retrieved from the vector. This block will catch if the key is present, which means they have played the game before. If they haven’t played before then execution will pass to the None block and the value is stored with the name.

	// Check to see if a record is alreay present for this user to store their
    // individual score
    match runtime::get_key(key.as_str()) {
        Some(key) => {
            // The user has played this game before
            let key_ref = key.try_into().unwrap_or_revert();
            let users_highest_score: i32 = storage::read(key_ref)
                .unwrap_or_revert_with(ApiError::Read)            
                .unwrap_or_revert_with(ApiError::ValueNotFound);

            // Check if they have beaten their current highscore
            if value > users_highest_score {
                // A new highscore for this player, so we update
                storage::write(key_ref, value);
            }
        }
        None => {
            // First time this user has played the game, so store their score
            let value_ref = storage::new_uref(value);
            let value_key = Key::URef(value_ref);
            runtime::put_key(key.as_str(), value_key);
        }
    }
}

If they have played the game before and the key is in the vector then their highest score is retrieved associated with their name. The new score is compared with the user’s current score. If it is higher then the record is updated with the new score.

The function to retrieve the high scores is a little simpler. First, there is a function to retrieve the highest score for that user. The passed in name is retrieved and a check is made to see if they already have a score. If a previous score is present then this value is returned. If they haven’t played the game before then a missing key error is returned.

#[no_mangle]
pub extern "C" fn highscore_get() {
    // Retrieve the name of the player
    let name: String = runtime::get_named_arg("name");
 
    // Check if they have a score and if so, return the value
    let uref: URef = runtime::get_key(&name)
        .unwrap_or_revert_with(ApiError::MissingKey)
        .into_uref()
        .unwrap_or_revert_with(ApiError::UnexpectedKeyVariant);
    let result: i32 = storage::read(uref)
        .unwrap_or_revert_with(ApiError::Read)
        .unwrap_or_revert_with(ApiError::ValueNotFound);
    runtime::ret(CLValue::from_t(result).unwrap_or_revert());
}

Finally, the call function can be defined, which is the main entry point for initializing the contract..

The variables for storing the highest score is defined and for creating the vector for the name/score lookup. Then the entry points are created for the contract, the set and get functions. Then the entry points are submitted along with the name of the contract.

#[no_mangle]
pub extern "C" fn call() {
    // Initialize the overall highscore to 0.
    let highscore_local_key = storage::new_uref(0_i32);
    // Initialize the name for the overall highscoring user
    let highscore_user_key = storage::new_uref("");

    // Create initial named keys of the contract.
    let mut highscore_named_keys: BTreeMap<String, Key> = BTreeMap::new();
    let key_name = String::from(HIGHSCORE_KEY);
    highscore_named_keys.insert(key_name, highscore_local_key.into());
    let key_user_name = String::from(HIGHSCORE_USER_KEY);
    highscore_named_keys.insert(key_user_name, highscore_user_key.into());
 
    // Create entry points to set the highscore value
    let mut highscore_entry_points = EntryPoints::new();
    highscore_entry_points.add_entry_point(EntryPoint::new(
        HIGHSCORE_SET,
        vec![
            Parameter::new("name", CLType::String),
            Parameter::new("value", CLType::I32)
        ],
        CLType::Unit,
        EntryPointAccess::Public,
        EntryPointType::Contract,
    ));
 
    // Create entry points to get the highscore value
    highscore_entry_points.add_entry_point(EntryPoint::new(
        HIGHSCORE_GET,
        vec![
            Parameter::new("name", CLType::String)
        ],
        CLType::String,
        EntryPointAccess::Public,
        EntryPointType::Contract,
    ));
 
    let (stored_contract_hash, _) = storage::new_locked_contract(highscore_entry_points, Some(highscore_named_keys), None, None);
    runtime::put_key(CONTRACT_KEY, stored_contract_hash.into());
}

Once that is saved, the cargo.toml file can be edited to declare which crates are being used. For this example we are using the casper-contract and casper-types crates.

[dependencies]
casper-contract = "1.4.4"
casper-types = "1.5.0"

Now the code is ready to compile and fix any errors:

Run the following command

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

If there are no errors then a WASM file will be created in the target folder. This is the file that will be used to deploy the contract to the blockchain.