Writing a Basic Smart Contract in Rust
What is a Smart Contract?
A smart contract is a self-contained program installed on a blockchain. In the context of a Casper network, a smart contract consists of contract code installed on-chain using a transaction. Casper smart contracts are programs that run on a Casper network. They interact with entities through entry points, allowing for various triggers, conditions, and logic.
Smart contracts exist as stored on-chain logic, allowing disparate users to call the included entry points. These contracts can, in turn, call one another to perform interconnected operations and create more complex programs. The decentralized nature of blockchain technology means that these smart contracts do not suffer from any single point of failure. Even if a Casper node leaves the network, other nodes will continue to allow the contract to operate as intended.
Key Features of Casper Contracts
On the Casper platform, developers may write smart contracts in any language that compiles to Wasm binaries. This tutorial focuses specifically on writing a smart contract in the Rust language. The Rust compiler compiles the contract code into Wasm. After that, the Wasm binary can be sent to a node on a Casper network using a transaction. Nodes within the network then gossip transactions, include them within a block, and finalize them. After finalizing, the network executes the transactions within the block.
Further, the Casper platform allows for upgradable contracts. A ContractPackage is created through the new_contract or new_locked_contract methods. Through these methods, the Casper execution engine automatically creates the new contract package and assigns a ContractPackageHash
. The new contract is added to this package with a ContractHash
key. The execution engine stores the new contract within the contract package alongside any previously installed contract versions, if applicable.
The new_contract
and new_locked_contract
methods are a convenience that automatically creates the package associated with a new contract. Developers choosing not to use these methods must first create a contract package to function as a container for their new contract.
The contract contains required metadata, and it is primarily identified by its ContractHash
. While the contract hash identifies a specific ContractVersion, the ContractPackageHash
serves as an identifier for the most recent contract version in the contract package.
Creating the Directory Structure
To begin creating a smart contract, you need to set up the project structure, either manually or automatically, as shown below.
project-directory/
└── contract/
├── src/
└── main.rs
└── Cargo.toml
└── Makefile
└── rust-toolchain
└── tests/
├── src/
└── integration-tests.rs
└── Cargo.toml
The project structure would be different in a dApp with full-stack architecture.
Automatically using cargo casper
The cargo casper command can automatically set up the project structure. This is the recommended way of setting up a new Casper project.
cargo casper my-project
The cargo casper
command will generate an example contract in the contract directory and an example tests
crate with logic defined in the integration-tests.rs
file. The Makefile
includes commands to prepare and build the contract, and the rust-toolchain
file specifies the target build version of Rust.
Semi-automatically using plain cargo
If you are a beginner, creating the structure automatically with cargo casper
is recommended and the command creates everything you need to start coding.
-
Create a top-level project directory for the contract code and its corresponding tests.
-
Inside the project directory, run the following command to create a new binary package called
contract
. Use a different name instead ofcontract
if you wish.cargo new contract
The command creates a
contract
folder with a/src/main.rs
file and aCargo.toml
file:main.rs
- This file would contain the contract code.Cargo.toml
- This file would contain crate dependencies and other configurations.
The following sections explain how to update these files using example code.
-
Inside the project directory, run the command to auto-generate the folder structure for the tests. Use a different name instead of
tests
if you wish.cargo new tests
The command creates a
tests
folder with a/src/main.rs
file and aCargo.toml
file:main.rs
- This file would store the unit test code required to test the contract. You can rename the file tointegration-tests.rs
as shown in the example structure.Cargo.toml
- This is the file with test configurations.
The Testing Smart Contracts guide explains how to update the tests using example code.
-
Unlike
cargo casper
,cargo
does not create aMakefile
andrust-toolchain
configuration file. Therefore, you must manually add these files to the project's root folder.
Example Makefile
prepare:
rustup target add wasm32-unknown-unknown
build-contract:
cd contract && cargo build --release --target wasm32-unknown-unknown
wasm-strip contract/target/wasm32-unknown-unknown/release/contract.wasm 2>/dev/null | true
test: build-contract
mkdir -p tests/wasm
cp contract/target/wasm32-unknown-unknown/release/contract.wasm tests/wasm
cd tests && cargo test
clippy:
cd contract && cargo clippy --all-targets -- -D warnings
cd tests && cargo clippy --all-targets -- -D warnings
check-lint: clippy
cd contract && cargo fmt -- --check
cd tests && cargo fmt -- --check
lint: clippy
cd contract && cargo fmt
cd tests && cargo fmt
Example rust-toolchain file
nightly-2022-08-03
Manually
If you are a beginner, creating the structure automatically with cargo casper
is recommended, and the command creates everything you need to start coding.
-
Create a top-level project directory to store the contract code and corresponding tests.
-
Create a folder for the contract code inside the project directory. This folder contains the logic that will be compiled into Wasm and executed on a Casper node. In this example, we named the folder
contract
. You can use a different folder name if you wish.- In the
contract
folder, add a source folder calledsrc
and aCargo.toml
file, which specifies the contract's dependencies. - Add a Rust file with the contract code in the
src
folder. In this example, we have themain.rs
file.
- In the
-
Navigating back to the project directory, create a folder for the tests, which help verify the contract's functionality. In this example, we named the folder
tests
.- In the
tests
folder, add a source folder calledsrc
and aCargo.toml
file, which specifies the required dependencies to run the tests. - In the
src
folder, add a Rust file with the tests that verify the contract's behavior. In this example, we have theintegration-tests.rs
file.
- In the
-
Manually create Makefile and rust-toolchain as per Semi-automatic setup (4.)
Dependencies
The Cargo.toml
file in the contract
folder includes the dependencies and versions the contract requires. At a minimum, you need to import the latest versions of the casper-contract and casper-types crates. The following dependencies and version numbers are only examples and must be adjusted based on your requirements.
If you followed the automatic setup, the dependencies should be in the Cargo.toml
file. For the semi-automatic setup and manual setup, however, you'll need to manually add the dependencies to the crate's Cargo.toml
file:
[dependencies]
# A library for developing Casper network smart contracts.
casper-contract = "1.4.4"
# Types shared by many Casper crates for use on a Casper network.
casper-types = "1.5.0"
casper-contract = "1.4.4"
- Provides the SDK for the execution engine (EE). The latest version of the crate is published here.casper-types = "1.5.0"
- Includes types shared by many Casper crates for use on a Casper network. This crate is necessary for the EE to understand and interpret the session code. The latest version of the crate is published here.
Writing a Basic Smart Contract
At this point, you either have the default example contract defined in contract/src/main.rs
(automatic setup using cargo-casper), an empty contract/src/main.rs
file (manual project setup), or a Rust "hello world" program defined in the contract/src/main.rs
(semi-automatic setup).
This section covers the process of writing a smart contract in Rust, step by step. Therefore, you should clear the contents of the contract/main.rs
file if there are any.
The example code comes from the counter contract. This simple contract allows callers to increment and retrieve an integer. Casper provides a contract API within the casper_contract
crate.
Updating the main.rs
File
To begin writing contract code, add the following file attributes to support the Wasm execution environment. If you still have an auto-generated main.rs
file, remove the auto-generated main function.
#![no_std]
#![no_main]
#![no_main]
- This attribute tells the program not to use the standard main function as its entry point.#![no_std]
- This attribute tells the program not to import the standard libraries.
Defining required dependencies
Add the required imports and dependencies. The example code for the counter contract declares the following dependencies.
// This code imports necessary aspects of external crates that we will use in our contract code.
extern crate alloc;
// Importing Rust types.
use alloc::{
string::{String, ToString},
vec::Vec,
};
// Importing aspects of the Casper platform.
use casper_contract::{
contract_api::{runtime, storage},
unwrap_or_revert::UnwrapOrRevert,
};
// Importing specific Casper types.
use casper_types::{
api_error::ApiError,
contracts::{EntryPoint, EntryPointAccess, EntryPointType, EntryPoints, NamedKeys},
CLType, CLValue, URef,
};
Defining the global constants
After importing the necessary dependencies, you should define the constants used within the contract, including entry points and values. The following example outlines the necessary constants for the counter contract.
// Creating constants for values within the contract package.
const CONTRACT_PACKAGE_NAME: &str = "counter_package_name";
const CONTRACT_ACCESS_UREF: &str = "counter_access_uref";
// Creating constants for the various contract entry points.
const ENTRY_POINT_COUNTER_INC: &str = "counter_inc";
const ENTRY_POINT_COUNTER_GET: &str = "counter_get";
// Creating constants for values within the contract.
const CONTRACT_VERSION_KEY: &str = "version";
const CONTRACT_KEY: &str = "counter";
const COUNT_KEY: &str = "count";
Defining the contract entry points
Entry points provide access to contract code installed in global state. Either session code or another smart contract may call these entry points. A contract must have at least one entry point and may have more than one entry point. Entry points are defined by their name, and those names should be clear and self-describing. Each entry point is equivalent to a static main entry point in a traditional program.
Entry points are not functions or methods, and they have no arguments. They are static entry points into the contract's logic. Yet, the contract logic can access parameters by name, passed along with the transaction. Note that another smart contract may access any of these entry points.
If an entry point has one or more mandatory parameters that will cause the logic to revert if they are not included, declare them within that entry point. Optional and non-critical parameters should be excluded.
When defining entry points, begin with a #[no_mangle]
line to ensure the system does not change critical syntax within the method names. Each entry point should contain the contract code that drives the action you wish it to accomplish. Finally, include any storage or return values needed, as applicable.
The following entry point is an example from the counter contract. To see all the available entry points, review the contract in GitHub.
#[no_mangle]
pub extern "C" fn counter_inc() {
let uref: URef = runtime::get_key(COUNT_KEY)
.unwrap_or_revert_with(ApiError::MissingKey)
.into_uref()
.unwrap_or_revert_with(ApiError::UnexpectedKeyVariant);
storage::add(uref, 1); // Increment the count by 1.
}
Defining the call
function
The call
function starts the code execution and installs the contract on-chain. In some cases, it also initializes some constructs, such as a Dictionary for record-keeping or a purse. The following steps describe how to structure the call
function. Review the call function in the counter contract.
- Define the runtime arguments.
At the time of contract installation, pass in parameters as runtime arguments. Use this pattern of variable definition to collect any sentinel values that dictate the behavior of the contract. If the entry point takes in arguments, you must declare those as part of the entry point's definition.
Look at the CEP-78 contract to see examples of entry points taking in arguments. The counter contract does not use variable parameters since it is too simple.
- Add the entry points into the
call
function.
The call
function replaces a traditional main
function and executes automatically when a caller interacts with the contract. Within the call
function, we define entry points the caller can access using session code or another contract. When writing code that calls an entry point, there must be a one-to-one mapping of the entry point name. Otherwise, the execution engine will return an error that the entry point does not exist.
Each entry point should have these arguments:
name
- The entry point's name, which should be the same as the initial definition.arguments
- A list of runtime arguments declared as part of the definition of the entry point.return type
- The CLType returned by the entry point. Use the type Unit for empty return types.access level
- Access permissions of the entry point.entry point type
- This can becontract
orsession
code.
This step adds the individual entry points to a counter_entry_points
object using the add_entry_point
method. This object will later be passed to the new_contract
method.
#[no_mangle]
pub extern "C" fn call() {
// Initialize the count to 0 locally
let count_start = storage::new_uref(0_i32);
// Create the entry points for this contract
let mut counter_entry_points = EntryPoints::new();
counter_entry_points.add_entry_point(EntryPoint::new(
ENTRY_POINT_COUNTER_GET,
Vec::new(),
CLType::I32,
EntryPointAccess::Public,
EntryPointType::Contract,
));
counter_entry_points.add_entry_point(EntryPoint::new(
ENTRY_POINT_COUNTER_INC,
Vec::new(),
CLType::Unit,
EntryPointAccess::Public,
EntryPointType::Contract,
));
}
In the following, we will add more content to this call function.
- Create the contract's named keys.
NamedKeys are a collection of String-Key pairs used to identify some network data quickly.
You can create named keys to store any record or value as needed, such as other accounts, smart contracts, URefs, transfers, transaction information, purse balances, etc. The list of possible Key variants can be found here.
For the counter, we store the integer that we increment into a named key.
// In the named keys of the counter contract, add a key for the count.
let mut counter_named_keys = NamedKeys::new();
let key_name = String::from(COUNT_KEY);
counter_named_keys.insert(key_name, count_start.into());
- Create the contract.
Use the new_contract method to create the contract with its named keys and entry points. This method creates the contract object and saves the access URef and the contract package hash in the caller's context. The execution engine automatically creates a contract package and assigns it a contractPackageHash
. Then, it adds the contract to the package with a contractHash
.
// Create a new contract package that can be upgraded.
let (stored_contract_hash, contract_version) = storage::new_contract(
counter_entry_points,
Some(counter_named_keys),
Some(CONTRACT_PACKAGE_NAME.to_string()),
Some(CONTRACT_ACCESS_UREF.to_string()),
);
Usually, these contracts are upgradeable with the ability to add new versions. You must have the access URef to the contract package to add a new contract version. This can be accomplished by passing the Some(CONTRACT_ACCESS_UREF.to_string())
argument to the new_contract
method. To prevent any upgrades to a contract, use the new_locked_contract
method described below.
- Create additional named keys.
Generally, the Contract_Hash
and Contract_Version
are saved as NamedKeys
for later use.
// Store the contract version in the context's named keys.
let version_uref = storage::new_uref(contract_version);
runtime::put_key(CONTRACT_VERSION_KEY, version_uref.into());
// Create a named key for the contract hash.
runtime::put_key(CONTRACT_KEY, stored_contract_hash.into());
The complete call function should look like this:
#[no_mangle]
pub extern "C" fn call() {
// Initialize the count to 0 locally
let count_start = storage::new_uref(0_i32);
// Create the entry points for this contract
let mut counter_entry_points = EntryPoints::new();
counter_entry_points.add_entry_point(EntryPoint::new(
ENTRY_POINT_COUNTER_GET,
Vec::new(),
CLType::I32,
EntryPointAccess::Public,
EntryPointType::Contract,
));
counter_entry_points.add_entry_point(EntryPoint::new(
ENTRY_POINT_COUNTER_INC,
Vec::new(),
CLType::Unit,
EntryPointAccess::Public,
EntryPointType::Contract,
));
// In the named keys of the counter contract, add a key for the count.
let mut counter_named_keys = NamedKeys::new();
let key_name = String::from(COUNT_KEY);
counter_named_keys.insert(key_name, count_start.into());
// Create a new contract package that can be upgraded.
let (stored_contract_hash, contract_version) = storage::new_contract(
counter_entry_points,
Some(counter_named_keys),
Some(CONTRACT_PACKAGE_NAME.to_string()),
Some(CONTRACT_ACCESS_UREF.to_string()),
);
/* To create a locked contract instead, use new_locked_contract and throw away the contract version returned
let (stored_contract_hash, _) =
storage::new_locked_contract(counter_entry_points, Some(counter_named_keys), None, None); */
// Store the contract version in the context's named keys.
let version_uref = storage::new_uref(contract_version);
runtime::put_key(CONTRACT_VERSION_KEY, version_uref.into());
// Create a named key for the contract hash.
runtime::put_key(CONTRACT_KEY, stored_contract_hash.into());
}
Locked Contracts
Locked contracts cannot contain other contract versions in the same contract package; thus, they cannot be upgraded. In this scenario, the Casper execution engine will create a contract package, add a contract to that package and prevent any further upgrades to the contract. Use locked contracts when you need to ensure high security and will not require updates to the contract.
pub fn new_locked_contract(
entry_points: EntryPoints,
named_keys: Option<NamedKeys>,
hash_name: Option<String>,
uref_name: Option<String>,
) -> (ContractHash, ContractVersion) {
create_contract(entry_points, named_keys, hash_name, uref_name, true)
}
entry_points
- The set of entry points defined inside the smart contract.named_keys
- Any named-key pairs for the contract.hash_name
- Contract hash value. Puts contractHash in the current context's named keys underhash_name
.uref_name
- Access URef value. Puts access_uref in the current context's named keys underuref_name
.
Note: The current context is the context of the person who initiated the call
function, usually an account entity.
The counter contract in our example would be locked if we created it this way:
let (stored_contract_hash, _) =
storage::new_locked_contract(counter_entry_points, Some(counter_named_keys), None, None);
Compiling Contract Code
To compile the smart contract, run the following commands in the contract
folder in your project's directory, where the Cargo.toml
file and src
folder are hosted.
rustup target add wasm32-unknown-unknown
cargo build --release --target wasm32-unknown-unknown
For the counter example, in the project directory where the Makefile
is, run the following commands:
make prepare
make build-contract
Executing Contract Code
Contract execution must be initiated through an outside call, usually via session code or another smart contract. Developers should also be familiar with the difference between contract code and session code, explained in the next section.
Video Walkthrough
The following brief video accompanies this guide.
What's Next?
- Learn to test your contract.
- Understand session code and how it triggers a smart contract.
- Learn to install a contract and query global state with the Casper command-line client.