Advanced Smart Contract Concepts with Solidity and Rust
In this tutorial, we will cover advanced Solidity and Rust concepts such as inheritance, interfaces, libraries, and modifiers. Additionally, we will learn how to write safe and efficient Rust code for smart contracts. Finally, we will learn how to convert common Solidity concepts to Rust.
Table of Contents
- Advanced Solidity Concepts
- Advanced Rust Concepts
- Writing Safe and Efficient Rust Code for Smart Contracts
- Solidity to Soroban: Common Concepts and Best Practices
Advanced Solidity Concepts
Inheritance
In Solidity, smart contracts can inherit properties and functions from other contracts. This is achieved using the is
keyword.
Here is an example of a parent contract that defines a function called messageFromParent
that returns a string:
contract Parent {
function messageFromParent() public pure returns (string memory) {
return "Hello from Parent";
}
}
contract Child is Parent {
function messageFromChild(string memory newMessage) public pure returns (string memory) {
string memory messageFromParent = messageFromParent();
return string(abi.encodePacked(messageFromParent,', ', newMessage));
}
}
In this example, the Child
contract inherits the messageFromParent
function from the Parent
contract. The Child
contract can then call the messageFromParent
function directly.
Interfaces
Interfaces are similar to contracts, but they cannot have any function implementations. They only contain function signatures. Contracts can implement interfaces using the is
keyword, similar to inheritance.
Here is an example of an interface that defines a function called doSomething
that returns a uint256
:
interface SomeInterface {
function doSomething() external returns (uint256);
}
contract SomeContract is SomeInterface {
uint256 private counter;
function doSomething() external override returns (uint256) {
counter += 1;
return counter;
}
}
In this example, the SomeContract
contract implements the SomeInterface
interface. Its implementation returns a u256
that is incremented each time the doSomething
function is called.
Libraries
Libraries are similar to contracts, but they cannot have any state variables. They are used to store reusable code that can be used by other contracts. Libraries are deployed once and can be used by multiple contracts. They are defined using the library
keyword. They are invoked by using the using
keyword.
library SafeMath {
function add(uint256 a, uint256 b) internal pure returns (uint256) {
uint256 c = a + b;
require(c >= a, "Addition overflow");
return c;
}
}
contract MyContract {
using SafeMath for uint256;
uint256 public value;
function increment(uint256 amount) public {
value = value.add(amount);
}
}
In this example, the SafeMath
library is used in the increment
function. The increment
function uses the add
function from the SafeMath
library to increment the value
variable.
Modifiers
Modifiers are used to change the behavior of functions in a declarative way. They are defined using the modifier
keyword. Modifiers can be used to perform common checks such as validating inputs, checking permissions, and more.
contract Ownable {
address public owner;
constructor() {
owner = msg.sender;
}
modifier onlyOwner() {
require(msg.sender == owner, "Caller is not the owner");
_;
}
}
contract MyContract is Ownable {
function doSomething() public onlyOwner {
// This function can only be called by the owner of the contract
}
}
In this example, the onlyOwner
modifier is used to restrict access to the doSomething
function. The doSomething
function can only be called by the owner
of the contract which was defined during deployment as msg.sender
.
Advanced Rust Concepts
Crates
A crate in Rust is a collection of precompiled programs, scripts, or routines that can be easily reused by programmers when writing code. This allows them to avoid reinventing the wheel by not having to implement the same logic or program multiple times. There are two types of crates in Rust: Binary crates and Library crates.
Binary crates are crates that can be executed as standalone programs. Library crates are crates that are meant to be used by other programs. Library crates can be imported into other programs using the use
keyword.
Here is an example of a workflow that implements allocation (alloc
) logic within a smart contract:
First a user would include the alloc
crate in their Cargo.toml
file:
[dependencies]
soroban-sdk = { workspace = true, features = ["alloc"] }
[dev_dependencies]
soroban-sdk = { workspace = true, features = ["testutils", "alloc"] }
Then they would import the alloc
crate into their smart contract:
// Imports
#![no_std]
use soroban_sdk::{contractimpl, Env};
extern crate alloc;
#[contract]
pub struct AllocContract;
#[contractimpl]
impl AllocContract {
/// Allocates a temporary vector holding values (0..count), then computes and returns their sum.
pub fn sum(_env: Env, count: u32) -> u32 {
let mut v1 = alloc::vec![];
(0..count).for_each(|i| v1.push(i));
let mut sum = 0;
for i in v1 {
sum += i;
}
sum
}
}
In this example, the alloc
crate is imported into the smart contract using the extern crate alloc;
statement. The alloc
crate is then used to create a temporary vector that holds values from 0 to count
. The values in the vector are then summed and returned.
For more details on how to use the alloc
crate, including a hands-on practical exercise, visit the alloc example contract.
Inheriting Functionality from Other Crates
We can illustrate another example of inheritance by importing functionality into a crate from other crates in the same project in the following example:
Below is a function from the event.rs
file from our Token example.
use soroban_sdk::{Address, Env, Symbol};
...
pub(crate) fn mint(e: &Env, admin: Address, to: Address, amount: i128) {
let topics = (symbol_short!("mint"), admin, to);
e.events().publish(topics, amount);
}
This function will publish a mint event to the blockchain with the following output:
Emit event with topics = ["mint", admin: Address, to: Address], data = amount: i128
We'll also use a function from our admin.rs
file.
// Metering: covered by components
pub fn read_administrator(e: &Host) -> Result<Address, HostError> {
let key = DataKey::Admin;
let rv = e.get_contract_data(key.try_into_val(e)?)?;
Ok(rv.try_into_val(e)?)
}
This function returns a Result
object that contains the administrator's address.
Lastly, we'll implement a function from our balance.rs
file.
pub fn receive_balance(e: &Env, addr: Address, amount: i128) {
let balance = read_balance(e, addr.clone());
if !is_authorized(e, addr.clone()) {
panic!("can't receive when deauthorized");
}
write_balance(e, addr, balance + amount);
}
This function writes an amount to an address' balance.
The event.rs
is imported into the contract.rs
file which holds the logic for our token contract.
//contract.rs
//imports
use crate::event;
use crate::admin::{read_administrator};
use crate::balance::{receive_balance};
// trait logic
pub trait TokenTrait {
fn mint(e: Env, to: Address, amount: i128);
}
// struct logic
#[contract]
pub struct Token;
// impl logic
#[contractimpl]
impl TokenTrait for Token {
fn mint(e: Env, to: Address, amount: i128) {
check_nonnegative_amount(amount);
let admin = read_administrator(&e);
admin.require_auth();
receive_balance(&e, to.clone(), amount);
event::mint(&e, admin, to, amount);
}
}
As you can see, the event.rs
, admin.rs
, and balance.rs
files are imported into the contract.rs
file using the use
keyword. This allows us to use the functions from those files in the mint
function of our contract.rs
file.
Inheriting Functionality using contractimport!
The Soroban Rust SDK provides a powerful macro, contractimport
, which allows a user to import a contract from its Wasm file, generating a client, types, and constant holding the contract file.
Here is an example of how to use the contractimport
macro taken from the token.rs file from our Liquidity Pool example:
First, we see that the wasm file from the previously built token example is imported into the token.rs
file using the contractimport
macro:
//token.rs
soroban_sdk::contractimport!(
file = "../token/target/wasm32-unknown-unknown/release/soroban_token_contract.wasm"
);
We see then that our token contract is imported into our lib.rs
file and a Client
is generated for us to access functionality from the token contract:
//lib.rs
mod token;
fn get_balance(e: &Env, contract_id: BytesN<32>) -> i128 {
token::Client::new(e, &contract_id).balance(&e.current_contract_address())
}
fn transfer(e: &Env, contract_id: BytesN<32>, to: Address, amount: i128) {
token::Client::new(e, &contract_id).transfer(&e.current_contract_address(), &to, &amount);
}
struct LiquidityPool;
#[contractimpl]
impl LiquidityPoolTrait for LiquidityPool {
let token_a_client = token::Client::new(&e, &get_token_a(&e));
}
In the above example, we use contractimport
to interact with the token file via a Client
that was generated for the token
module. This Client
was created using a Contract trait that matches the interface of the contract, a ContractClient struct that contains functions for each function in the contract, and types for all contract types defined in the contract.
A Note on Inheritance and Composability
While we've been using the term "inheritance" to help make the transition from Solidity smoother, let's clarify an important aspect of Rust: it does not support inheritance as we traditionally understand it. Instead, Rust practices "composability", meaning it uses functions from different crates, which are akin to packages, in a modular fashion. So, when we discuss contractimport!
, we're actually observing composability in action, not "inheritance". Rust does not foster the "is a" relationship inherent in OOP languages. Instead, it enables us to reuse and assemble code effectively across different scopes. This is a technical truth that is important to understand; however, it's worth noting that this fact doesn't impact the practical usage of Soroban throughout this guide.
Modules
In Rust, modules consist of a cohesive set of related functions and types that are often organized together for better organization and reusability. These modules can be reused across multiple projects by publishing them as crates.
Here is an example of a module that implements SafeMath
logic with an add
function:
#![no_std]
mod safe_math
// mod safe_math {
// pub fn add(a: u32, b: u32) -> Result<u32, &'static str> {
// a.checked_add(b).ok_or("Addition overflow")
// }
// }
// Imports
use soroban_sdk::{contractimpl, Env};
use safe_math::add;
pub trait MathContract {
fn add(&self, env: Env, a: u32, b: u32) -> u32;
}
#[contract]
pub struct Adder;
impl MathContract for Adder {
fn add(&self, _env: Env, a: u32, b: u32) -> u32 {
add(a, b).unwrap()
}
}
#[contractimpl]
impl Adder {}
// test module
#[cfg(test)]
mod test;
Notice that we use the checked_add
function from the standard library to ensure that the addition does not overflow. This is important because if the addition overflows, it could lead to unexpected behavior in the contract.
Even when Rust code is compiled with the #![no_std]
flag, it is still possible to use some of the standard library's features, such as the checked_add
function. This is because Rust provides the option to selectively import modules and functions from the standard library, allowing developers to use only the specific features they need.
Traits
Rust does not have a built-in modifier system like Solidity. However, you can achieve similar functionality using traits
and their implementations.
In the example below, we will illustrate the inheritance of traits using the Ownable
trait.
#![no_std]
// Imports
use soroban_sdk::{contracttype, Address};
// Define the `Ownable` trait
trait Ownable {
fn is_owner(&self, owner: &Address) -> bool;
}
// Implement the `Ownable` trait for the `OwnableContract` struct
impl Ownable for OwnableContract {
fn is_owner(&self, owner: &Address) -> bool {
self.owner == *owner
}
}
// Define a modifier that requires the caller to be the owner of the contract
fn only_owner(contract: &OwnableContract, owner: &Address) -> bool {
contract.is_owner(owner)
}
// Implement the contract for the `OwnableContract` struct
#[contracttype]
// Define the `OwnableContract` struct
pub struct OwnableContract {
owner: Address,
number: u32,
}
impl OwnableContract {
// Define a public method that requires the caller to be the owner of the contract
pub fn change_number(&mut self, new_number: u32) {
if only_owner(self, &self.owner) {
self.number = new_number;
}
}
}
#[cfg(test)]
mod test;
Here's a breakdown of the code above:
- First, we define the
Ownable
trait, which defines a single method calledis_owner
. This method takes anAddress
as an argument and returns a boolean value indicating whether or not the address is the owner of the contract. - Next, we implement the
Ownable
trait for theOwnableContract
struct. This allows us to use theis_owner
method on instances of theOwnableContract
struct. - Then, we define a "modifier" called
only_owner
that takes an instance of theOwnableContract
struct and anAddress
as arguments. This "modifier" returns a boolean value indicating whether or not the address is the owner of the contract. - Finally, we implement the contract for the
OwnableContract
struct. This allows only theowner
of the contract to use thechange_number
method on instances of theOwnableContract
struct.
It's worth mentioning that the Soroban Rust SDK comes with several built-in requirements that developers can use, such as the require_auth
method provided by the Address
struct.
Interfaces
Interfaces are an essential part of building smart contracts with Soroban.
There are many types of smart contract interfaces, and each has a specific purpose. One example of an interface built with Soroban is the Token Interface. This interface ensures that tokens deployed on Soroban are interoperable with Soroban's built-in tokens (such as the Stellar Asset Contract). The Token Interface consists of three compatibility requirements:
function interface
authorization
events
For more information on smart contract interfaces built with Soroban, including the Token Interface, visit the tokens section of the documentation.
Writing Safe and Efficient Rust Code for Smart Contracts
When writing Rust code for smart contracts, it's important to focus on safety and efficiency. Some tips include:
- Use the
Result
type to handle errors in a safe and predictable way. In smart contracts, it's important to avoid panicking, as this can lead to unpredictable behavior. Instead, Result can be used to handle errors and ensure that the contract behaves as expected.
In the example below, the add
function returns a Result
type, which can either be Ok
or Err
. If the addition does not overflow, the function returns Ok
, otherwise it returns Err
.
pub fn add(a: u32, b: u32) -> Result<u32, &'static str> {
a.checked_add(b).ok_or("Addition overflow")
}
- Use the
checked_
family of functions, such aschecked_add
,checked_sub
, etc., to perform arithmetic operations in a safe and efficient manner. These functions check for overflows and underflows and return an error if one occurs.
In the example below, the add
function uses the checked_add
function to perform the addition. If the addition overflows, the function returns an error.
pub fn add(a: u32, b: u32) -> u32 {
a.checked_add(b).expect("Addition overflow")
}
- Use
cargo
andclippy
to enforce code quality, style, and efficiency in Rust.cargo
is Rust's package manager and provides a number of tools for building and testing Rust code.clippy
is a linter that can help identify potential issues in the code, such as unused variables or functions that could be optimized.
To use clippy with cargo, you'll first need to install it. You can do this by running the following command in your terminal:
cargo install clippy
Once clippy is installed, you can run it by running the following command in your terminal:
cargo clippy
This will run clippy on your entire project, checking for potential issues and providing suggestions for improvement. Clippy will output any issues it finds, along with suggestions for how to fix them.
- Use
cargo
andrustfmt
to enforce code style.rustfmt
is a tool that can automatically format Rust code according to the Rust style guide. This can help ensure that the code is consistent and easy to read.
To use rustfmt with cargo, you'll first need to install it. You can do this by running the following command in your terminal:
cargo install rustfmt
Once rustfmt is installed, you can run it by running the following command in your terminal:
cargo fmt
Before:
fn main()
{
let x=5;
if x==5 {
println!("Hello, world!");
}
}
After:
fn main() {
let x = 5;
if x == 5 {
println!("Hello, world!");
}
}
Solidity to Soroban: Common Concepts and Best Practices
In this section we will explore key Solidity concepts and provide their Soroban equivalents. We will discuss the following topics:
- Message Properties
- Error Handling
- Address-related functionality
- Function visibility specifiers
- Time-based variables
Message Properties
The Soroban Rust SDK and Solidity provide a number of message properties that can be used to access information about the current transaction. These properties include:
Solidity
msg.sender
: The address of the account that sent the transaction.msg.value
: The amount of Ether sent with the transaction.msg.data
: The data sent with the transaction.
Here's a simple example of a smart contract that demonstrates the use of each
pragma solidity ^0.8.0;
contract SimpleContract {
address public sender;
uint public value;
bytes public data;
// Caller must send Ether and data to this function.
// This function will store the sender, value, and data.
function sendData(bytes calldata _data) external payable {
sender = msg.sender;
value = msg.value;
data = _data;
}
}
These are a part of Solidity's global variables, which are accessible from any function in the contract.
Soroban
In contrast to Solidity's global variables, Soroban relies on passing an Env
argument to all functions which provides access to the environment the contract is executing within.
The Env
provides access to information about the currently executing contract, who invoked it, contract data, functions for signing, hashing, etc.
For instance, you would use env.storage().persistent().get(key)
to access a persistent
target value from the contract's storage. Read more about the different storage types here.
env.storage()
is used to get a struct for accessing and updating contract data that has been stored.- Used as
env.storage().persistent().get()
orenv.persistent().storage().set()
. - Additionally, we utilize the
clone()
method, a prevalent trait in Rust that allows for the explicit duplication of an object.
See the example below for implementations of env.storage()
and clone()
env.storage().persistent().set()
use soroban_sdk::{Env, Symbol};
pub fn set_storage(env: Env) {
let key = symbol_short!("key");
let value = symbol_short!("value");
env.storage().persistent().set(&key, &value);
}
env.storage().persistent().get()
use soroban_sdk::{Env};
pub fn get_storage(env: Env) -> value {
env.storage().persistent().get(&key);
}
clone()
use soroban_sdk::{Env, Address};
pub fn return_user(user: Address) -> Address {
let user_address: Address = user.clone();
user_address
}