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
}
Error Handling
The Soroban Rust SDK and Solidity provide a number of ways to handle errors. These include:
Solidity
Solidity provides a require
function that can be used to check for certain conditions and revert the transaction if they are not met. For example, the following code sets a minimum value for the amount of Ether sent with the transaction:
function deposit() public payable {
require(msg.value >= 1 ether, "Not enough Ether sent");
// ...
}
Soroban
The panic! macro serves as Rust's error-handling mechanism, which closely resembles the require
function in Solidity.
pub fn simple_deposit(amount: u32) {
if amount < 1_000_000 {
panic!("amount too low");
}
// ...
}
Address-Related Functionality
Both Soroban and Solidity provide provide a number of functions for working with addresses. These functions include:
Solidity
address(this)
: Returns the address of the current contract.address payable(this)
: Returns the address of the current contract as a payable address.address(address)
: Returns the address of the specified account.address payable(address)
: Returns the address of the specified account as a payable address.
Below is an example of a smart contract that illustrates how contracts can be retrieved
pragma solidity ^0.8.0;
contract SimpleContract {
address public contractAddress = address(this);
address public randomAddress = 0x1234567890123456789012345678901234567890;
address public payableAddress = payable(address(this));
address public payableRandomAddress = payable(0x1234567890123456789012345678901234567890);
}
There would be no difference in appearance between a regular address and a payable address in Solidity.
Soroban
e.current_contract_address()
: Returns the Address object corresponding to the current executing contract.
The Env
not only provides essential information about the currently executing contract and its invoker, but also offers access to contract data and functions for signing, hashing, and more. The construction or conversion of most types in Soroban requires access to an Env
instance.
Here is an example of a smart contract that illustrates how contracts can be retrieved:
#![no_std]
use soroban_sdk::{contractimpl, log, Address, Env, Symbol};
#[contract]
pub struct SimpleContract;
#[contractimpl]
impl SimpleContract {
///Example contract for returning a contract Address.
pub fn return_address(env: Env) -> Address {
let current_contract_address = env.current_contract_address();
current_contract_address
}
}
Why Soroban Differs
Soroban has some differences from Solidity in terms of addresses and other functionalities. These differences arise due to the design principles and goals of Soroban.
One significant difference is the use of the Env
object in Soroban. The Env
object encapsulates various functionalities related to contract execution, data access, and more. It provides a unified interface for interacting with the Soroban environment within the context of a contract. By utilizing the Env
object, Soroban enables a more modular and flexible approach to contract development.
To further explain, the Env
type offers a gateway to the environment where the contract operates. It provides information about the ongoing contract, the entity invoking it, contract data, and functions for signing, hashing, and so forth. Most types demand access to an Env
for their construction or conversion.
Meanwhile, the Address
object serves as a potent tool for authentication and authorization. For instance, it can be used to authorize token transfers, acting as a security gatekeeper within the system. This feature amplifies the functionality of addresses in Soroban, making them not just a means of identification or storage, but also a key player in verifying and authorizing transactions.
Function Visibility Specifiers
The Soroban Rust SDK and Solidity provide a number of function visibility specifiers that can be used to control who can call a function. These specifiers include:
Solidity
public
: Anyone can call the function.external
: Only other contracts can call the function.internal
: Only the current contract and contracts that inherit from it can call the function.private
: Only the current contract can call the function.
Here is an example of a smart contract that illustrates how function visibility is used in Solidity:
pragma solidity ^0.8.0;
contract SimpleContract {
function publicFunction() public {}
function externalFunction() external {}
function internalFunction() internal {}
function privateFunction() private {}
}
Soroban
pub
: The item (function, struct, etc.) is accessible from any module or scope.pub(crate)
: The item is accessible only within the current crate.pub(super)
: The item is accessible only within its parent module.pub(in path::to::module)
: The item is accessible only within the specified module path.private
: The item is not marked as pub and is therefore private to its own module, meaning it can only be accessed within the same module and is not accessible from outside the module.
Here is an example of a module that illustrates how function visibility is used in Soroban:
#![no_std]
use soroban_sdk::{contractimpl, Env};
mod outer {
pub struct PublicStruct {
pub field: u32,
}
pub(crate) struct CrateStruct {
pub(crate) field: u32,
}
// This struct is private because it is not marked as `pub`.
struct PrivateStruct {
field: u32,
}
mod inner {
pub(super) struct SuperStruct {
pub(super) field: u32,
}
}
pub fn get_all_fields() -> u32 {
let public_struct = PublicStruct { field: 1 };
let crate_struct = CrateStruct { field: 2 };
let private_struct = PrivateStruct { field: 3 };
let super_struct = inner::SuperStruct { field: 4 };
public_struct.field + crate_struct.field + private_struct.field + super_struct.field
}
}
#[contract]
pub struct NewContract;
trait OuterTrait {
fn get_all_fields() -> u32;
fn get_public_fields() -> u32;
}
#[contractimpl]
impl OuterTrait for NewContract {
fn get_public_fields() -> u32 {
let public_struct = outer::PublicStruct { field: 1 };
let crate_struct = outer::CrateStruct { field: 2 };
// private structs cannot be accessed from outside the crate
public_struct.field + crate_struct.field
}
fn get_all_fields() -> u32 {
outer::get_all_fields()
}
}
Time-Based Variables
The Soroban Rust SDK and Solidity provide a number of time-based variables that can be used to access information about the current block(EVM) or ledger(Soroban). These variables include:
Solidity
block.timestamp
: The timestamp of the current block.block.number
: The number of the current block.
Here is an example of a smart contract that illustrates how time-based variables are used in Solidity:
pragma solidity ^0.8.0;
contract SimpleContract {
function getTimestamp() public view returns (uint256) {
return block.timestamp;
}
function getBlockNumber() public view returns (uint256) {
return block.number;
}
}
Soroban
env.ledger().timestamp()
: Returns a unix timestamp for when the most recent ledger was closed.env.ledger().sequence()
: Returns the sequence number of the most recently closed ledger.
Here is an example of a smart contract that illustrates how time-based variables are used in Rust:
#![no_std]
use soroban_sdk::{contractimpl, Address, Env};
#[contract]
pub struct SimpleContract;
#[contractimpl]
impl SimpleContract {
pub fn get_timestamp(env: Env) {
let timestamp = env.ledger().timestamp();
let sequence = env.ledger().sequence();
}
}
Authorization
Soroban differs from Solidity in its approach to authorization and modifiers. While Solidity has a built-in modifier system, Sorotban does not. Instead, Soroban leverages traits, their implementations, and core features to achieve similar functionality.
Solidity
In Solidity, the ERC20 token standard includes the approve
function, which allows a token holder to authorize another address to spend a certain amount of tokens on their behalf. This function is commonly used in decentralized exchanges and other token transfer scenarios. Furthermore, we're ensuring that the spender is authorized to spend the amount of tokens requested by the token holder.
Here's an example of how the approve function acts as an authorization mechanism in Solidity:
pragma solidity ^0.8.0;
contract ERC20Token {
constructor() {
address owner = msg.sender;
ownerAddress = owner;
}
mapping(address => uint256) public balances;
mapping(address => mapping(address => uint256)) public allowances;
mapping(address => bool) public isAuthorized;
address public ownerAddress;
function approve(address spender, uint256 amount) public returns (bool) {
allowances[msg.sender][spender] = amount;
return true;
}
function setAuthorization(address newAuth) public returns (bool) {
require(msg.sender == ownerAddress);
isAuthorized[newAuth] = true;
return true;
}
function transfer(address to, uint256 amount) public returns (bool) {
require(allowances[msg.sender][to] >= amount, "Not enough allowance");
require(isAuthorized[to] == true, "Not authorized");
balances[msg.sender] -= amount;
balances[to] += amount;
return true;
}
}
The approve function allows the token holder to authorize spender to spend amount tokens on their behalf. The transfer function then checks if the spender is authorized to spend the amount of tokens requested by the token holder. If so, the transfer is executed.
These Solidity examples illustrate some common authorization patterns used in Ethereum smart contracts. Soroban provides alternative approaches to achieve similar functionality, leveraging core functionality derived right from the soroban SDK.
Soroban
Soroban's design principles prioritize flexibility, security, and testability, which have led to differences in how authorization is handled compared to Solidity.
Soroban provides built-in functions such as require_auth
and require_auth_for_args
through the Address
struct. These functions help enforce authorization rules within contracts. During on-chain execution, the Soroban host performs the necessary authentication, including verifying signatures and ensuring replay prevention. This alleviates the burden of authentication from the contracts themselves, promoting security and reducing potential vulnerabilities.
Here is an example of a smart contract that illustrates how authorization is handled in Soroban:
#![no_std]
use soroban_sdk::{contractimpl, testutils::Address as _, Address, Symbol, Env, IntoVal};
#[contract]
pub struct Contract;
#[contractimpl]
impl Contract {
pub fn transfer(env: Env, address: Address, amount: i128) {
address.require_auth();
}
pub fn transfer2(env: Env, address: Address, amount: i128) {
address.require_auth_for_args((amount / 2,).into_val(&env));
}
}
In this example, we have a Soroban contract that includes two public functions: transfer
and transfer2
, both of which involve authorization checks.
Inside the Transfer function, the require_auth
method is invoked on the address object. This method ensures that the caller of the contract has the necessary authorization to execute the transfer.
The transfer2
function follows a similar pattern but uses the require_auth_for_args
method instead. It takes the same parameters as transfer but provides a tuple (amount / 2,) as the argument to require_auth_for_args
. This method verifies that the caller has authorized the contract invocation with the specific arguments.
By utilizing these authorization methods provided by the Address
object from the Soroban Rust SDK, the contract enforces that only authorized callers can perform the transfers. This approach enhances the security of the contract by ensuring that sensitive operations can only be executed by authorized parties.
Soroban's approach to authorization in this example offers several advantages over Solidity's model of ERC20 by eliminating the need for separate approval management. Instead, authorization checks can be directly incorporated into any Soroban function. This simplifies the contract codebase and reduces the complexity associated with managing separate approval states.
Soroban authorization provides Contract-level Authorization, Account Abstraction Functionality, and more advanced Authorization checks. To learn more about these advantages, visit the Authorization section of the documentation.
Summary
Overall the Soroban equivalents of Solidity concepts are very similar. However, there are notable differences worth highlighting. Soroban uses env
instead of msg
to access information about the entire contract execution environment, including the state of the contract, addresses involved, and more. Authorization is also handled differently in Soroban, as it is built into the core functionality of the SDK and is more robust than relying on smart contract code alone.
For more information on Solidity concepts and their Soroban equivalents, it is recommended that one refer to both the Soroban Rust SDK documentation and the Solidity documentation.