Starknet by Example
Starknet By Example is a collection of examples of how to use the Cairo programming language to create smart contracts on Starknet.
Starknet is a permissionless Validity-Rollup that supports general computation. It is currently used as an Ethereum layer-2. Starknet use the STARK cryptographic proof system to ensure high safety and scalability.
Starknet smart contracts are written in the Cairo language. Cairo is a Turing-complete programming language designed to write provable programs, abstracting the zk-STARK proof system away from the programmer.
The current version of this book use:
scarb 2.3.1
For whom is this for?
Starknet By Example is for anyone who wants to quickly learn how to write smart contracts on Starknet using Cairo with some technical background in programming and blockchain.
The first chapters will give you a basic understanding of the Cairo programming language and how to write, deploy and use smart contracts on Starknet. The later chapters will cover more advanced topics and show you how to write more complex smart contracts.
Further reading
If you want to learn more about the Cairo programming language, you can read the Cairo Book. If you want to learn more about Starknet, you can read the Starknet documentation and the Starknet Book.
Here's a list of other resources that you might find useful:
- Starklings: An interactive tutorial to get you up and running with Cairo v1 and Starknet
- Cairopractice: A blog with a series of articles about Cairo and Starknet
- Cairo by example: An introduction to Cairo, with simple examples
Basics of Smart Contracts in Cairo
The following chapters will introduce you to Starknet smart contracts and how to write them in Cairo.
Storage
Here's the most minimal contract you can write in Cairo:
#[starknet::contract]
mod Contract {
#[storage]
struct Storage {}
}
Storage is a struct annoted with #[storage]
. Every contract must have one and only one storage.
It's a key-value store, where each key will be mapped to a storage address of the contract's storage space.
You can define storage variables in your contract, and then use them to store and retrieve data.
#[starknet::contract]
mod Contract {
#[storage]
struct Storage {
a: u128,
b: u8,
c: u256
}
}
Actually these two contracts have the same underlying sierra program. From the compiler's perspective, the storage variables don't exist until they are used.
You can also read about storing custom types
Constructor
Constructors are a special type of function that runs only once when deploying a contract, and can be used to initialize the state of the contract. Your contract must not have more than one constructor, and that constructor function must be annotated with the #[constructor]
attribute. Also, a good practice consists in naming that function constructor
.
Here's a simple example that demonstrates how to initialize the state of a contract on deployment by defining logic inside a constructor.
#[starknet::contract]
mod ExampleConstructor {
use starknet::ContractAddress;
#[storage]
struct Storage {
names: LegacyMap::<ContractAddress, felt252>,
}
// The constructor is decorated with a `#[constructor]` attribute.
// It is not inside an `impl` block.
#[constructor]
fn constructor(ref self: ContractState, name: felt252, address: ContractAddress) {
self.names.write(address, name);
}
}
Visit contract on Voyager or play with it in Remix.
Variables
There are 3 types of variables in Cairo contracts:
- Local
- declared inside a function
- not stored on the blockchain
- Storage
- declared in the Storage of a contract
- can be accessed from one execution to another
- Global
- provides information about the blockchain
- accessed anywhere, even within library functions
Local Variables
Local variables are used and accessed within the scope of a specific function or block of code. They are temporary and exist only for the duration of that particular function or block execution.
Local variables are stored in memory and are not stored on the blockchain. This means they cannot be accessed from one execution to another. Local variables are useful for storing temporary data that is relevant only within a specific context. They also make the code more readable by giving names to intermediate values.
Here's a simple example of a contract with only local variables:
#[starknet::interface]
trait ILocalVariablesExample<TContractState> {
fn do_something(self: @TContractState, value: u32) -> u32;
}
#[starknet::contract]
mod LocalVariablesExample {
#[storage]
struct Storage {}
#[abi(embed_v0)]
impl LocalVariablesExample of super::ILocalVariablesExample<ContractState> {
fn do_something(self: @ContractState, value: u32) -> u32 {
// This variable is local to the current block. It can't be accessed once it goes out of scope.
let increment = 10;
{
// The scope of a code block allows for local variable declaration
// We can access variables defined in higher scopes.
let sum = value + increment;
sum
}
}
}
}
Visit contract on Voyager or play with it in Remix.
Storage Variables
Storage variables are persistent data stored on the blockchain. They can be accessed from one execution to another, allowing the contract to remember and update information over time.
To write or update a storage variable, you need to interact with the contract through an external entrypoint by sending a transaction.
On the other hand, you can read state variables, for free, without any transaction, simply by interacting with a node.
Here's a simple example of a contract with one storage variable:
#[starknet::interface]
trait IStorageVariableExample<TContractState> {
fn set(ref self: TContractState, value: u32);
fn get(self: @TContractState) -> u32;
}
#[starknet::contract]
mod StorageVariablesExample {
// All storage variables are contained in a struct called Storage
// annotated with the `#[storage]` attribute
#[storage]
struct Storage {
// Storage variable holding a number
value: u32
}
#[abi(embed_v0)]
impl StorageVariablesExample of super::IStorageVariableExample<ContractState> {
// Write to storage variables by sending a transaction that calls an external function
fn set(ref self: ContractState, value: u32) {
self.value.write(value);
}
// Read from storage variables without sending transactions
fn get(self: @ContractState) -> u32 {
self.value.read()
}
}
}
Visit contract on Voyager or play with it in Remix.
Global Variables
Global variables are predefined variables that provide information about the blockchain and the current execution environment. They can be accessed at any time and from anywhere!
In Starknet, you can access global variables by using specific functions contained in the starknet core libraries.
For example, the get_caller_address
function returns the address of the caller of the current transaction, and the get_contract_address
function returns the address of the current contract.
#[starknet::interface]
trait IGlobalExample<TContractState> {
fn foo(ref self: TContractState);
}
#[starknet::contract]
mod GlobalExample {
// import the required functions from the starknet core library
use starknet::get_caller_address;
#[storage]
struct Storage {}
#[abi(embed_v0)]
impl GlobalExampleImpl of super::IGlobalExample<ContractState> {
fn foo(ref self: ContractState) {
// Call the get_caller_address function to get the sender address
let caller = get_caller_address();
// ...
}
}
}
Visit contract on Voyager or play with it in Remix.
Visibility and Mutability
Visibility
There are two types of functions in Starknet contracts:
- Functions that are accessible externally and can be called by anyone.
- Functions that are only accessible internally and can only be called by other functions in the contract.
These functions are also typically divided into two different implementations blocks. The first impl
block for externally accessible functions is explicitly annotated with an #[abi(embed_v0)]
attribute. This indicates that all the functions inside this block can be called either as a transaction or as a view function. The second impl
block for internally accessible functions is not annotated with any attribute, which means that all the functions inside this block are private by default.
State Mutability
Regardless of whether a function is internal or external, it can either modify the contract's state or not. When we declare functions that interact with storage variables inside a smart contract,
we need to explicitly state that we are accessing the ContractState
by adding it as the first parameter of the function. This can be done in two different ways:
- If we want our function to be able to mutate the state of the contract, we pass it by reference like this:
ref self: ContractState
. - If we want our function to be read-only and not mutate the state of the contract, we pass it by snapshot like this:
self: @ContractState
.
Read-only functions, also called view functions, can be directly called without making a transaction. You can interact with them directly through a RPC node to read the contract's state, and they're free to call! External functions, that modify the contract's state, on the other side can only be called by making a transaction.
Internal functions can't be called externally, but the same principle applies regarding state mutability.
Let's take a look at a simple example contract to see these in action:
#[starknet::interface]
trait IExampleContract<TContractState> {
fn set(ref self: TContractState, value: u32);
fn get(self: @TContractState) -> u32;
}
#[starknet::contract]
mod ExampleContract {
#[storage]
struct Storage {
value: u32
}
// The `abi(embed_v0)` attribute indicates that all the functions in this implementation can be called externally.
// Omitting this attribute would make all the functions in this implementation internal.
#[abi(embed_v0)]
impl ExampleContract of super::IExampleContract<ContractState> {
// The `set` function can be called externally because it is written inside an implementation marked as `#[external]`.
// It can modify the contract's state as it is passed as a reference.
fn set(ref self: ContractState, value: u32) {
self.value.write(value);
}
// The `get` function can be called externally because it is written inside an implementation marked as `#[external]`.
// However, it can't modify the contract's state is passed as a snapshot: it is only a "view" function.
fn get(self: @ContractState) -> u32 {
// We can call an internal function from any functions within the contract
PrivateFunctionsTrait::_read_value(self)
}
}
// The lack of the `external` attribute indicates that all the functions in this implementation can only be called internally.
// We name the trait `PrivateFunctionsTrait` to indicate that it is an internal trait allowing us to call internal functions.
#[generate_trait]
impl PrivateFunctions of PrivateFunctionsTrait {
// The `_read_value` function is outside the implementation that is marked as `#[abi(embed_v0)]`, so it's an _internal_ function
// and can only be called from within the contract.
// However, it can't modify the contract's state is passed as a snapshot: it is only a "view" function.
fn _read_value(self: @ContractState) -> u32 {
self.value.read()
}
}
}
Visit contract on Voyager or play with it in Remix.
Simple Counter
This is a simple counter contract.
Here's how it works:
-
The contract has a state variable called 'counter' that is initialized to 0.
-
When a user calls 'increment', the contract increments the counter by 1.
-
When a user calls 'decrement', the contract decrements the counter by 1.
#[starknet::interface]
trait ISimpleCounter<TContractState> {
fn get_current_count(self: @TContractState) -> u128;
fn increment(ref self: TContractState);
fn decrement(ref self: TContractState);
}
#[starknet::contract]
mod SimpleCounter {
#[storage]
struct Storage {
// Counter variable
counter: u128,
}
#[constructor]
fn constructor(ref self: ContractState, init_value: u128) {
// Store initial value
self.counter.write(init_value);
}
#[abi(embed_v0)]
impl SimpleCounter of super::ISimpleCounter<ContractState> {
fn get_current_count(self: @ContractState) -> u128 {
return self.counter.read();
}
fn increment(ref self: ContractState) {
// Store counter value + 1
let counter = self.counter.read() + 1;
self.counter.write(counter);
}
fn decrement(ref self: ContractState) {
// Store counter value - 1
let counter = self.counter.read() - 1;
self.counter.write(counter);
}
}
}
Visit contract on Voyager or play with it in Remix.
Mappings
Maps are a key-value data structure used to store data within a smart contract. In Cairo they are implemented using the LegacyMap
type. It's important to note that the LegacyMap
type can only be used inside the Storage
struct of a contract and that it can't be used elsewhere.
Here we demonstrate how to use the LegacyMap
type within a Cairo contract, to map between a key of type ContractAddress
and value of type felt252
. The key-value types are specified within angular brackets <>. We write to the map by calling the write()
method, passing in both the key and value. Similarly, we can read the value associated with a given key by calling the read()
method and passing in the relevant key.
Some additional notes:
-
More complex key-value mappings are possible, for example we could use
LegacyMap::<(ContractAddress, ContractAddress), felt252>
to create an allowance on an ERC20 token contract. -
In mappings, the address of the value at key
k_1,...,k_n
ish(...h(h(sn_keccak(variable_name),k_1),k_2),...,k_n)
whereℎ
is the Pedersen hash and the final value is takenmod2251−256
. You can learn more about the contract storage layout in the Starknet Documentation
use starknet::ContractAddress;
#[starknet::interface]
trait IMapContract<TContractState> {
fn set(ref self: TContractState, key: ContractAddress, value: felt252);
fn get(self: @TContractState, key: ContractAddress) -> felt252;
}
#[starknet::contract]
mod MapContract {
use starknet::ContractAddress;
#[storage]
struct Storage {
// The `LegacyMap` type is only available inside the `Storage` struct.
map: LegacyMap::<ContractAddress, felt252>,
}
#[abi(embed_v0)]
impl MapContractImpl of super::IMapContract<ContractState> {
fn set(ref self: ContractState, key: ContractAddress, value: felt252) {
self.map.write(key, value);
}
fn get(self: @ContractState, key: ContractAddress) -> felt252 {
self.map.read(key)
}
}
}
Visit contract on Voyager or play with it in Remix.
Errors
Errors can be used to handle validation and other conditions that may occur during the execution of a smart contract. If an error is thrown during the execution of a smart contract call, the execution is stopped and any changes made during the transaction are reverted.
To throw an error, use the assert
or panic
functions:
-
assert
is used to validate conditions. If the check fails, an error is thrown along with a specified value, often a message. It's similar to therequire
statement in Solidity. -
panic
immediately halt the execution with the given error value. It should be used when the condition to check is complex and for internal errors. It's similar to therevert
statement in Solidity. (Usepanic_with_felt252
to be able to directly pass a felt252 as the error value)
Here's a simple example that demonstrates the use of these functions:
#[starknet::interface]
trait IErrorsExample<TContractState> {
fn test_assert(self: @TContractState, i: u256);
fn test_panic(self: @TContractState, i: u256);
}
#[starknet::contract]
mod ErrorsExample {
#[storage]
struct Storage {}
#[abi(embed_v0)]
impl ErrorsExample of super::IErrorsExample<ContractState> {
fn test_assert(self: @ContractState, i: u256) {
// Assert used to validate a condition
// and abort execution if the condition is not met
assert(i > 0, 'i must be greater than 0');
}
fn test_panic(self: @ContractState, i: u256) {
if (i == 0) {
// Panic used to abort execution directly
panic_with_felt252('i must not be 0');
}
}
}
}
Visit contract on Voyager or play with it in Remix.
Custom errors
You can make error handling easier by defining your error codes in a specific module.
mod Errors {
const NOT_POSITIVE: felt252 = 'must be greater than 0';
const NOT_NULL: felt252 = 'must not be null';
}
#[starknet::interface]
trait ICustomErrorsExample<TContractState> {
fn test_assert(self: @TContractState, i: u256);
fn test_panic(self: @TContractState, i: u256);
}
#[starknet::contract]
mod CustomErrorsExample {
use super::Errors;
#[storage]
struct Storage {}
#[abi(embed_v0)]
impl CustomErrorsExample of super::ICustomErrorsExample<ContractState> {
fn test_assert(self: @ContractState, i: u256) {
assert(i > 0, Errors::NOT_POSITIVE);
}
fn test_panic(self: @ContractState, i: u256) {
if (i == 0) {
panic_with_felt252(Errors::NOT_NULL);
}
}
}
}
Visit contract on Voyager or play with it in Remix.
Vault example
Here's another example that demonstrates the use of errors in a more complex contract:
mod VaultErrors {
const INSUFFICIENT_BALANCE: felt252 = 'insufficient_balance';
// you can define more errors here
}
#[starknet::interface]
trait IVaultErrorsExample<TContractState> {
fn deposit(ref self: TContractState, amount: u256);
fn withdraw(ref self: TContractState, amount: u256);
}
#[starknet::contract]
mod VaultErrorsExample {
use super::VaultErrors;
#[storage]
struct Storage {
balance: u256,
}
#[abi(embed_v0)]
impl VaultErrorsExample of super::IVaultErrorsExample<ContractState> {
fn deposit(ref self: ContractState, amount: u256) {
let mut balance = self.balance.read();
balance = balance + amount;
self.balance.write(balance);
}
fn withdraw(ref self: ContractState, amount: u256) {
let mut balance = self.balance.read();
assert(balance >= amount, VaultErrors::INSUFFICIENT_BALANCE);
// Or using panic:
if (balance >= amount) {
panic_with_felt252(VaultErrors::INSUFFICIENT_BALANCE);
}
let balance = balance - amount;
self.balance.write(balance);
}
}
}
Visit contract on Voyager or play with it in Remix.
Events
Events are a way to emit data from a contract. All events must be defined in the Event
enum, which must be annotated with the #[event]
attribute.
An event is defined as struct that derives the #[starknet::Event]
trait. The fields of that struct correspond to the data that will be emitted. An event can be indexed for easy and fast access when querying the data at a later time. Events data can be indexed by adding a #[key]
attribute to a field member.
Here's a simple example of a contract using events that emit an event each time a counter is incremented by the "increment" function:
#[starknet::interface]
trait IEventCounter<TContractState> {
fn increment(ref self: TContractState);
}
#[starknet::contract]
mod EventCounter {
use starknet::{get_caller_address, ContractAddress};
#[storage]
struct Storage {
// Counter value
counter: u128,
}
#[event]
#[derive(Drop, starknet::Event)]
// The event enum must be annotated with the `#[event]` attribute.
// It must also derive the `Drop` and `starknet::Event` traits.
enum Event {
CounterIncreased: CounterIncreased,
UserIncreaseCounter: UserIncreaseCounter
}
// By deriving the `starknet::Event` trait, we indicate to the compiler that
// this struct will be used when emitting events.
#[derive(Drop, starknet::Event)]
struct CounterIncreased {
amount: u128
}
#[derive(Drop, starknet::Event)]
struct UserIncreaseCounter {
// The `#[key]` attribute indicates that this event will be indexed.
#[key]
user: ContractAddress,
new_value: u128,
}
#[abi(embed_v0)]
impl EventCounter of super::IEventCounter<ContractState> {
fn increment(ref self: ContractState) {
let mut counter = self.counter.read();
counter += 1;
self.counter.write(counter);
// Emit event
self.emit(Event::CounterIncreased(CounterIncreased { amount: 1 }));
self
.emit(
Event::UserIncreaseCounter(
UserIncreaseCounter {
user: get_caller_address(), new_value: self.counter.read()
}
)
);
}
}
}
Visit contract on Voyager or play with it in Remix.
Storing Custom Types
While native types can be stored in a contract's storage without any additional work, custom types require a bit more work. This is because at compile time, the compiler does not know how to store custom types in storage. To solve this, we need to implement the Store
trait for our custom type. Hopefully, we can just derive this trait for our custom type - unless it contains arrays or dictionaries.
#[starknet::interface]
trait IStoringCustomType<TContractState> {
fn set_person(ref self: TContractState, person: Person);
}
// Deriving the starknet::Store trait
// allows us to store the `Person` struct in the contract's storage.
#[derive(Drop, Serde, Copy, starknet::Store)]
struct Person {
age: u8,
name: felt252
}
#[starknet::contract]
mod StoringCustomType {
use super::Person;
#[storage]
struct Storage {
person: Person
}
#[abi(embed_v0)]
impl StoringCustomType of super::IStoringCustomType<ContractState> {
fn set_person(ref self: ContractState, person: Person) {
self.person.write(person);
}
}
}
Play with this contract in Remix.
Custom types in entrypoints
Using custom types in entrypoints requires our type to implement the Serde
trait. This is because when calling an entrypoint, the input is sent as an array of felt252
to the entrypoint, and we need to be able to deserialize it into our custom type. Similarly, when returning a custom type from an entrypoint, we need to be able to serialize it into an array of felt252
.
Thankfully, we can just derive the Serde
trait for our custom type.
#[starknet::interface]
trait ISerdeCustomType<TContractState> {
fn person_input(ref self: TContractState, person: SerdeCustomType::Person);
fn person_output(self: @TContractState) -> SerdeCustomType::Person;
}
#[starknet::contract]
mod SerdeCustomType {
#[storage]
struct Storage {}
// Deriving the `Serde` trait allows us to use
// the Person type as an entrypoint parameter and return value
#[derive(Drop, Serde)]
struct Person {
age: u8,
name: felt252
}
#[abi(embed_v0)]
impl SerdeCustomType of super::ISerdeCustomType<ContractState> {
fn person_input(ref self: ContractState, person: Person) {}
fn person_output(self: @ContractState) -> Person {
Person { age: 10, name: 'Joe' }
}
}
}
Play with this contract in Remix.
Documentation
It's important to take the time to document your code. It will helps developers and users to understand the contract and its functionalities.
In Cairo, you can add comments with //
.
Best Practices:
Since Cairo 1, the community has adopted a Rust-like documentation style.
Contract Interface:
In smart contracts, you will often have a trait that defines the contract's interface (with #[starknet::interface]
).
This is the perfect place to include detailed documentation explaining the purpose and functionality of the contract entry points. You can follow this template:
#[starknet::interface]
trait IContract<TContractState> {
/// High-level description of the function
///
/// # Arguments
///
/// * `arg_1` - Description of the argument
/// * `arg_n` - ...
///
/// # Returns
///
/// High-level description of the return value
fn do_something(ref self: TContractState, arg_1: T_arg_1) -> T_return;
}
Keep in mind that this should not describe the implementation details of the function, but rather the high-level purpose and functionality of the contract from the perspective of a user.
Implementation Details:
When writing the logic of the contract, you can add comments to describe the technical implementation details of the functions.
Avoid over-commenting: Comments should provide additional value and clarity.
Deploy and interact with contracts
In this chapter, we will see how to deploy and interact with contracts.
Contract interfaces and Traits generation
Contract interfaces define the structure and behavior of a contract, serving as the contract's public ABI. They list all the function signatures that a contract exposes. For a detailed explanation of interfaces, you can refer to the Cairo Book.
In cairo, to specify the interface you need to define a trait annotated with #[starknet::interface]
and then implement that trait in the contract.
When a function needs to access the contract state, it must have a self
parameter of type ContractState
. This implies that the corresponding function signature in the interface trait must also take a TContractState
type as a parameter. It's important to note that every function in the contract interface must have this self
parameter of type TContractState
.
You can use the #[generate_trait]
attribute to implicitly generate the trait for a specific implementation block. This attribute automatically generates a trait with the same functions as the ones in the implemented block, replacing the self
parameter with a generic TContractState
parameter. However, you will need to annotate the block with the #[abi(per_item)]
attribute, and each function with the appropriate attribute depending on whether it's an external function, a constructor or a l1 handler.
In summary, there's two ways to handle interfaces:
- Explicitly, by defining a trait annoted with
#[starknet::interface]
- Implicitly, by using
#[generate_trait]
combined with the #[abi(per_item)]` attributes, and annotating each function inside the implementation block with the appropriate attribute.
Explicit interface
#[starknet::interface]
trait IExplicitInterfaceContract<TContractState> {
fn get_value(self: @TContractState) -> u32;
fn set_value(ref self: TContractState, value: u32);
}
#[starknet::contract]
mod ExplicitInterfaceContract {
#[storage]
struct Storage {
value: u32
}
#[abi(embed_v0)]
impl ExplicitInterfaceContract of super::IExplicitInterfaceContract<ContractState> {
fn get_value(self: @ContractState) -> u32 {
self.value.read()
}
fn set_value(ref self: ContractState, value: u32) {
self.value.write(value);
}
}
}
Play with this contract in Remix.
Implicit interface
#[starknet::contract]
mod ImplicitInterfaceContract {
#[storage]
struct Storage {
value: u32
}
#[abi(per_item)]
#[generate_trait]
impl ImplicitInterfaceContract of IImplicitInterfaceContract {
#[external(v0)]
fn get_value(self: @ContractState) -> u32 {
self.value.read()
}
#[external(v0)]
fn set_value(ref self: ContractState, value: u32) {
self.value.write(value);
}
}
}
Play with this contract in Remix.
Note: You can import an implicitly generated contract interface with
use contract::{GeneratedContractInterface}
. However, theDispatcher
will not be generated automatically.
Internal functions
You can also use #[generate_trait]
for your internal functions.
Since this trait is generated in the context of the contract, you can define pure functions as well (functions without the self
parameter).
#[starknet::interface]
trait IImplicitInternalContract<TContractState> {
fn add(ref self: TContractState, nb: u32);
fn get_value(self: @TContractState) -> u32;
fn get_const(self: @TContractState) -> u32;
}
#[starknet::contract]
mod ImplicitInternalContract {
#[storage]
struct Storage {
value: u32
}
#[generate_trait]
impl InternalFunctions of InternalFunctionsTrait {
fn set_value(ref self: ContractState, value: u32) {
self.value.write(value);
}
fn get_const() -> u32 {
42
}
}
#[constructor]
fn constructor(ref self: ContractState) {
self.set_value(0);
}
#[abi(embed_v0)]
impl ImplicitInternalContract of super::IImplicitInternalContract<ContractState> {
fn add(ref self: ContractState, nb: u32) {
self.set_value(self.value.read() + nb);
}
fn get_value(self: @ContractState) -> u32 {
self.value.read()
}
fn get_const(self: @ContractState) -> u32 {
self.get_const()
}
}
}
Play with this contract in Remix.
Calling other contracts
There are two different ways to call other contracts in Cairo.
The easiest way to call other contracts is by using the dispatcher of the contract you want to call. You can read more about Dispatchers in the Cairo Book
The other way is to use the starknet::call_contract_syscall
syscall yourself. However, this method is not recommended.
In order to call other contracts using dispatchers, you will need to define the called contract's interface as a trait annotated with the #[starknet::interface]
attribute, and then import the IContractDispatcher
and IContractDispatcherTrait
items in your contract.
#[starknet::interface]
trait ICallee<TContractState> {
fn set_value(ref self: TContractState, value: u128) -> u128;
}
#[starknet::contract]
mod Callee {
#[storage]
struct Storage {
value: u128,
}
#[abi(embed_v0)]
impl ICalleeImpl of super::ICallee<ContractState> {
fn set_value(ref self: ContractState, value: u128) -> u128 {
self.value.write(value);
value
}
}
}
Visit contract on Voyager or play with it in Remix.
use starknet::ContractAddress;
// We need to have the interface of the callee contract defined
// so that we can import the Dispatcher.
#[starknet::interface]
trait ICallee<TContractState> {
fn set_value(ref self: TContractState, value: u128) -> u128;
}
#[starknet::interface]
trait ICaller<TContractState> {
fn set_value_from_address(ref self: TContractState, addr: ContractAddress, value: u128);
}
#[starknet::contract]
mod Caller {
// We import the Dispatcher of the called contract
use super::{ICalleeDispatcher, ICalleeDispatcherTrait};
use starknet::ContractAddress;
#[storage]
struct Storage {}
#[abi(embed_v0)]
impl ICallerImpl of super::ICaller<ContractState> {
fn set_value_from_address(ref self: ContractState, addr: ContractAddress, value: u128) {
ICalleeDispatcher { contract_address: addr }.set_value(value);
}
}
}
Visit contract on Voyager or play with it in Remix.
Factory Pattern
The factory pattern is a well known pattern in object oriented programming. It provides an abstraction on how to instantiate a class.
In the case of smart contracts, we can use this pattern by defining a factory contract that have the sole responsibility of creating and managing other contracts.
Class hash and contract instance
In Starknet, there's a separation between contract's classes and instances. A contract class serves as a blueprint, defined by the underling Cairo bytecode, contract's entrypoints, ABI and Sierra program hash. The contract class is identified by a class hash. When you want to add a new class to the network, you first need to declare it.
When deploying a contract, you need to specify the class hash of the contract you want to deploy. Each instance of a contract has their own storage regardless of the class hash.
Using the factory pattern, we can deploy multiple instances of the same contract class and handle upgrades easily.
Minimal example
Here's a minimal example of a factory contract that deploy the SimpleCounter
contract:
use starknet::{ContractAddress, ClassHash};
#[starknet::interface]
trait ICounterFactory<TContractState> {
/// Create a new counter contract from stored arguments
fn create_counter(ref self: TContractState) -> ContractAddress;
/// Create a new counter contract from the given arguments
fn create_counter_at(ref self: TContractState, init_value: u128) -> ContractAddress;
/// Update the argument
fn update_init_value(ref self: TContractState, init_value: u128);
/// Update the class hash of the Counter contract to deploy when creating a new counter
fn update_counter_class_hash(ref self: TContractState, counter_class_hash: ClassHash);
}
#[starknet::contract]
mod CounterFactory {
use starknet::{ContractAddress, ClassHash};
use starknet::syscalls::deploy_syscall;
#[storage]
struct Storage {
/// Store the constructor arguments of the contract to deploy
init_value: u128,
/// Store the class hash of the contract to deploy
counter_class_hash: ClassHash,
}
#[constructor]
fn constructor(ref self: ContractState, init_value: u128, class_hash: ClassHash) {
self.init_value.write(init_value);
self.counter_class_hash.write(class_hash);
}
#[abi(embed_v0)]
impl Factory of super::ICounterFactory<ContractState> {
fn create_counter_at(ref self: ContractState, init_value: u128) -> ContractAddress {
// Contructor arguments
let mut constructor_calldata: Array::<felt252> = array![init_value.into()];
// Contract deployment
let (deployed_address, _) = deploy_syscall(
self.counter_class_hash.read(), 0, constructor_calldata.span(), false
)
.expect('failed to deploy counter');
deployed_address
}
fn create_counter(ref self: ContractState) -> ContractAddress {
self.create_counter_at(self.init_value.read())
}
fn update_init_value(ref self: ContractState, init_value: u128) {
self.init_value.write(init_value);
}
fn update_counter_class_hash(ref self: ContractState, counter_class_hash: ClassHash) {
self.counter_class_hash.write(counter_class_hash);
}
}
}
This factory can be used to deploy multiple instances of the SimpleCounter
contract by calling the create_counter
and create_counter_at
functions.
The SimpleCounter
class hash is stored inside the factory, and can be upgraded with the update_counter_class_hash
function which allows to reuse the same factory contract when the SimpleCounter
contract is upgraded.
This minimal example lacks several useful features such as access control, tracking of deployed contracts, events, ...
Contract Testing
Testing plays a crucial role in software development, especially for smart contracts. In this section, we'll guide you through the basics of testing a smart contract on Starknet with scarb
.
Let's start with a simple smart contract as an example:
use starknet::ContractAddress;
#[starknet::interface]
trait ISimpleContract<TContractState> {
fn get_value(self: @TContractState) -> u32;
fn get_owner(self: @TContractState) -> ContractAddress;
fn set_value(ref self: TContractState, value: u32);
}
#[starknet::contract]
mod SimpleContract {
use starknet::{get_caller_address, ContractAddress};
#[storage]
struct Storage {
value: u32,
owner: ContractAddress
}
#[constructor]
fn constructor(ref self: ContractState, initial_value: u32) {
self.value.write(initial_value);
self.owner.write(get_caller_address());
}
#[abi(embed_v0)]
impl SimpleContract of super::ISimpleContract<ContractState> {
fn get_value(self: @ContractState) -> u32 {
self.value.read()
}
fn get_owner(self: @ContractState) -> ContractAddress {
self.owner.read()
}
fn set_value(ref self: ContractState, value: u32) {
assert(self.owner.read() == get_caller_address(), 'Not owner');
self.value.write(value);
}
}
}
Now, take a look at the tests for this contract:
#[cfg(test)]
mod tests {
// Import the interface and dispatcher to be able to interact with the contract.
use testing_how_to::contract::{
ISimpleContract, SimpleContract, ISimpleContractDispatcher, ISimpleContractDispatcherTrait
};
// Import the deploy syscall to be able to deploy the contract.
use starknet::class_hash::Felt252TryIntoClassHash;
use starknet::{
deploy_syscall, ContractAddress, get_caller_address, get_contract_address,
contract_address_const
};
// Use starknet test utils to fake the transaction context.
use starknet::testing::{set_caller_address, set_contract_address};
// Deploy the contract and return its dispatcher.
fn deploy(initial_value: u32) -> ISimpleContractDispatcher {
// Set up constructor arguments.
let mut calldata = ArrayTrait::new();
initial_value.serialize(ref calldata);
// Declare and deploy
let (contract_address, _) = deploy_syscall(
SimpleContract::TEST_CLASS_HASH.try_into().unwrap(), 0, calldata.span(), false
)
.unwrap();
// Return the dispatcher.
// The dispatcher allows to interact with the contract based on its interface.
ISimpleContractDispatcher { contract_address }
}
#[test]
#[available_gas(2000000000)]
fn test_deploy() {
let initial_value: u32 = 10;
let contract = deploy(initial_value);
assert(contract.get_value() == initial_value, 'wrong initial value');
assert(contract.get_owner() == get_contract_address(), 'wrong owner');
}
#[test]
#[available_gas(2000000000)]
fn test_set_as_owner() {
// Fake the caller address to address 1
let owner = contract_address_const::<1>();
set_contract_address(owner);
let contract = deploy(10);
assert(contract.get_owner() == owner, 'wrong owner');
// Fake the contract address to address 1
set_contract_address(owner);
let new_value: u32 = 20;
contract.set_value(new_value);
assert(contract.get_value() == new_value, 'wrong value');
}
#[test]
#[should_panic]
#[available_gas(2000000000)]
fn test_set_not_owner() {
let owner = contract_address_const::<1>();
set_contract_address(owner);
let contract = deploy(10);
let not_owner = contract_address_const::<2>();
set_contract_address(not_owner);
let new_value: u32 = 20;
contract.set_value(new_value);
}
}
Play with this contract in Remix.
To define our test, we use scarb, which allows us to create a separate module guarded with #[cfg(test)]
. This ensures that the test module is only compiled when running tests using scarb test
.
Each test is defined as a function with the #[test]
attribute. You can also check if a test panics using the #[should_panic]
attribute.
As we are in the context of a smart contract, it's essential to set up the gas limit. You do this by using the #[available_gas(X)]
attribute to specify the gas limit for a test. This is also a great way to ensure that your contract's features stay under a certain gas limit!
Note: The term "gas" here refers to Sierra gas, not L1 gas
Now, let's move on to the testing process:
- Use the
deploy
function logic to declare and deploy your contract. - Use
assert
to verify that the contract behaves as expected in the given context.
To make testing more convenient, the testing
module of the corelib provides some helpful functions:
set_caller_address(address: ContractAddress)
set_contract_address(address: ContractAddress)
set_block_number(block_number: u64)
set_block_timestamp(block_timestamp: u64)
set_account_contract_address(address: ContractAddress)
set_max_fee(fee: u128)
You may also need the info
module from the corelib, which allows you to access information about the current transaction context:
get_caller_address() -> ContractAddress
get_contract_address() -> ContractAddress
get_block_info() -> Box<BlockInfo>
get_tx_info() -> Box<TxInfo>
get_block_timestamp() -> u64
get_block_number() -> u64
You can found the full list of functions in the Starknet Corelib repo. You can also find a detailled explaination of testing in cairo in the Cairo book - Chapter 8.
Starknet Foundry
Starknet Foundry is a powerful toolkit for developing smart contracts on Starknet. It offers support for testing Starknet smart contracts on top of scarb
with the Forge
tool.
Testing with snforge
is similar to the process we just described but simplified. Moreover, additional features are on the way, including cheatcodes or parallel tests execution. We highly recommend exploring Starknet Foundry and incorporating it into your projects.
For more detailed information about testing contracts with Starknet Foundry, check out the Starknet Foundry Book - Testing Contracts.
Cairo Cheatsheet
This chapter aims to provide a quick reference for the most common Cairo constructs.
Felt252
Felt252 is a fundamental data type in Cairo from which all other data types are derived. Felt252 can also be used to store short-string representations with a maximum length of 31 characters.
For example:
let felt: felt252 = 100;
let felt_as_str = 'Hello Starknet!';
let felt = felt + felt_as_str;
Mapping
The LegacyMap
type can be used to represent a collection of key-value.
use starknet::ContractAddress;
#[starknet::interface]
trait IMappingExample<TContractState> {
fn register_user(ref self: TContractState, student_add: ContractAddress, studentName: felt252);
fn record_student_score(
ref self: TContractState, student_add: ContractAddress, subject: felt252, score: u16
);
fn view_student_name(self: @TContractState, student_add: ContractAddress) -> felt252;
fn view_student_score(
self: @TContractState, student_add: ContractAddress, subject: felt252
) -> u16;
}
#[starknet::contract]
mod MappingContract {
use starknet::ContractAddress;
#[storage]
struct Storage {
students_name: LegacyMap::<ContractAddress, felt252>,
students_result_record: LegacyMap::<(ContractAddress, felt252), u16>,
}
#[abi(embed_v0)]
impl External of super::IMappingExample<ContractState> {
fn register_user(
ref self: ContractState, student_add: ContractAddress, studentName: felt252
) {
self.students_name.write(student_add, studentName);
}
fn record_student_score(
ref self: ContractState, student_add: ContractAddress, subject: felt252, score: u16
) {
self.students_result_record.write((student_add, subject), score);
}
fn view_student_name(self: @ContractState, student_add: ContractAddress) -> felt252 {
self.students_name.read(student_add)
}
fn view_student_score(
self: @ContractState, student_add: ContractAddress, subject: felt252
) -> u16 {
// for a 2D mapping its important to take note of the amount of brackets being used.
self.students_result_record.read((student_add, subject))
}
}
}
Arrays
Arrays are collections of elements of the same type.
The possible operations on arrays are defined with the array::ArrayTrait
of the corelib:
trait ArrayTrait<T> {
fn new() -> Array<T>;
fn append(ref self: Array<T>, value: T);
fn pop_front(ref self: Array<T>) -> Option<T> nopanic;
fn pop_front_consume(self: Array<T>) -> Option<(Array<T>, T)> nopanic;
fn get(self: @Array<T>, index: usize) -> Option<Box<@T>>;
fn at(self: @Array<T>, index: usize) -> @T;
fn len(self: @Array<T>) -> usize;
fn is_empty(self: @Array<T>) -> bool;
fn span(self: @Array<T>) -> Span<T>;
}
For example:
fn array() -> bool {
let mut arr = ArrayTrait::<u32>::new();
arr.append(10);
arr.append(20);
arr.append(30);
assert(arr.len() == 3, 'array length should be 3');
let first_value = arr.pop_front().unwrap();
assert(first_value == 10, 'first value should match');
let second_value = *arr.at(0);
assert(second_value == 20, 'second value should match');
// Returns true if an array is empty, then false if it isn't.
arr.is_empty()
}
Loop
A loop specifies a block of code that will run repetitively until a halting condition is encountered. For example:
let mut arr = ArrayTrait::new();
// Same as ~ while (i < 10) arr.append(i++);
let mut i: u32 = 0;
let limit = 10;
loop {
if i == limit {
break;
};
arr.append(i);
i += 1;
};
Match
The "match" expression in Cairo allows us to control the flow of our code by comparing a felt data type or an enum against various patterns and then running specific code based on the pattern that matches. For example:
#[derive(Drop, Serde)]
enum Colour {
Red,
Blue,
Green,
Orange,
Black
}
#[derive(Drop, Serde)]
enum Coin {
Penny,
Nickel,
Dime,
Quarter,
}
fn value_in_cents(coin: Coin) -> felt252 {
match coin {
Coin::Penny => 1,
Coin::Nickel => 5,
Coin::Dime => 10,
Coin::Quarter => 25,
}
}
fn specified_colour(colour: Colour) -> felt252 {
let mut response: felt252 = '';
match colour {
Colour::Red => { response = 'You passed in Red'; },
Colour::Blue => { response = 'You passed in Blue'; },
Colour::Green => { response = 'You passed in Green'; },
Colour::Orange => { response = 'You passed in Orange'; },
Colour::Black => { response = 'You passed in Black'; },
};
response
}
fn quiz(num: felt252) -> felt252 {
let mut response: felt252 = '';
match num {
0 => { response = 'You failed' },
_ => { response = 'You Passed' },
};
response
}
Tuples
Tuples is a data type to group a fixed number of items of potentially different types into a single compound structure. Unlike arrays, tuples have a set length and can contain elements of varying types. Once a tuple is created, its size cannot change. For example:
let address = "0x000";
let age = 20;
let active = true;
// Create tuple
let user_tuple = (address, age, active);
// Access tuple
let (address, age, active) = stored_tuple;
Struct
A struct is a data type similar to tuple. Like tuples they can be used to hold data of different types. For example:
// With Store, you can store Data's structs in the storage part of contracts.
#[derive(Drop, starknet::Store)]
struct Data {
address: starknet::ContractAddress,
age: u8
}
Type casting
Cairo supports the conversion from one scalar types to another by using the into and try_into methods.
traits::Into
is used for conversion from a smaller data type to a larger data type, while traits::TryInto
is used when converting from a larger to a smaller type that might not fit.
For example:
let a_number: u32 = 15;
let my_felt252 = 15;
// Since a u32 might not fit in a u8 and a u16, we need to use try_into,
// then unwrap the Option<T> type thats returned.
let new_u8: u8 = a_number.try_into().unwrap();
let new_u16: u16 = a_number.try_into().unwrap();
// since new_u32 is the of the same type (u32) as rand_number, we can directly assign them,
// or use the .into() method.
let new_u32: u32 = a_number;
// When typecasting from a smaller size to an equal or larger size we use the .into() method.
// Note: u64 and u128 are larger than u32, so a u32 type will always fit into them.
let new_u64: u64 = a_number.into();
let new_u128: u128 = a_number.into();
// Since a felt252 is smaller than a u256, we can use the into() method
let new_u256: u256 = my_felt252.into();
let new_felt252: felt252 = new_u16.into();
//note a usize is smaller than a felt so we use the try_into
let new_usize: usize = my_felt252.try_into().unwrap();
Upgradeable Contract
In Starknet, contracts are divided into two parts: contract classes and contract instances. This division follows a similar concept used in object-oriented programming languages, where we distinguish between the definition and implementation of objects.
A contract class is the definition of a contract: it specifies how the contract behaves. It contains essential information like the Cairo byte code, hint information, entry point names, and everything that defines its semantics unambiguously.
To identify different contract classes, Starknet assigns a unique identifier to each class: the class hash. A contract instance is a deployed contract that corresponds to a specific contract class. Think of it as an instance of an object in languages like Java.
Each class is identified by its class hash, which is analogous to a class name in an object-oriented programming language. A contract instance is a deployed contract corresponding to a class.
You can upgrade a deployed contract to a newer version by calling the replace_class_syscall
function. By using this function, you can update the class hash associated with a deployed contract, effectively upgrading its implementation. However, this will not modify the contract's storage, so all the data stored in the contract will remain the same.
To illustrate this concept, let's consider an example with two contracts: UpgradeableContract_V0
, and UpgradeableContract_V1
.
Start by deploying UpgradeableContract_V0
as the initial version. Next, send a transaction that invokes the upgrade
function, with the class hash of UpgradeableContract_V1
as parameter to upgrade the class hash of the deployed contract to the UpgradeableContract_V1
one. Then, call the version
method on the contract to see that the contract was upgraded to the V1 version.
use starknet::class_hash::ClassHash;
#[starknet::interface]
trait IUpgradeableContract<TContractState> {
fn upgrade(ref self: TContractState, impl_hash: ClassHash);
fn version(self: @TContractState) -> u8;
}
#[starknet::contract]
mod UpgradeableContract_V0 {
use starknet::class_hash::ClassHash;
use starknet::SyscallResultTrait;
#[storage]
struct Storage {}
#[event]
#[derive(Drop, starknet::Event)]
enum Event {
Upgraded: Upgraded
}
#[derive(Drop, starknet::Event)]
struct Upgraded {
implementation: ClassHash
}
#[abi(embed_v0)]
impl UpgradeableContract of super::IUpgradeableContract<ContractState> {
fn upgrade(ref self: ContractState, impl_hash: ClassHash) {
assert(!impl_hash.is_zero(), 'Class hash cannot be zero');
starknet::replace_class_syscall(impl_hash).unwrap_syscall();
self.emit(Event::Upgraded(Upgraded { implementation: impl_hash }))
}
fn version(self: @ContractState) -> u8 {
0
}
}
}
Visit contract on Voyager or play with it in Remix.
use starknet::class_hash::ClassHash;
#[starknet::interface]
trait IUpgradeableContract<TContractState> {
fn upgrade(ref self: TContractState, impl_hash: ClassHash);
fn version(self: @TContractState) -> u8;
}
#[starknet::contract]
mod UpgradeableContract_V1 {
use starknet::class_hash::ClassHash;
use starknet::SyscallResultTrait;
#[storage]
struct Storage {}
#[event]
#[derive(Drop, starknet::Event)]
enum Event {
Upgraded: Upgraded
}
#[derive(Drop, starknet::Event)]
struct Upgraded {
implementation: ClassHash
}
#[abi(embed_v0)]
impl UpgradeableContract of super::IUpgradeableContract<ContractState> {
fn upgrade(ref self: ContractState, impl_hash: ClassHash) {
assert(!impl_hash.is_zero(), 'Class hash cannot be zero');
starknet::replace_class_syscall(impl_hash).unwrap_syscall();
self.emit(Event::Upgraded(Upgraded { implementation: impl_hash }))
}
fn version(self: @ContractState) -> u8 {
1
}
}
}
Visit contract on Voyager or play with it in Remix.
Simple Defi Vault
This is the Cairo adaptation of the Solidity by example Vault. Here's how it works:
-
When a user deposits a token, the contract calculates the amount of shares to mint.
-
When a user withdraws, the contract burns their shares, calculates the yield, and withdraw both the yield and the initial amount of token deposited.
use starknet::{ContractAddress};
// In order to make contract calls within our Vault,
// we need to have the interface of the remote ERC20 contract defined to import the Dispatcher.
#[starknet::interface]
trait IERC20<TContractState> {
fn name(self: @TContractState) -> felt252;
fn symbol(self: @TContractState) -> felt252;
fn decimals(self: @TContractState) -> u8;
fn total_supply(self: @TContractState) -> u256;
fn balance_of(self: @TContractState, account: ContractAddress) -> u256;
fn allowance(self: @TContractState, owner: ContractAddress, spender: ContractAddress) -> u256;
fn transfer(ref self: TContractState, recipient: ContractAddress, amount: u256) -> bool;
fn transfer_from(
ref self: TContractState, sender: ContractAddress, recipient: ContractAddress, amount: u256
) -> bool;
fn approve(ref self: TContractState, spender: ContractAddress, amount: u256) -> bool;
}
#[starknet::interface]
trait ISimpleVault<TContractState> {
fn deposit(ref self: TContractState, amount: u256);
fn withdraw(ref self: TContractState, shares: u256);
}
#[starknet::contract]
mod SimpleVault {
use super::{IERC20Dispatcher, IERC20DispatcherTrait};
use starknet::{ContractAddress, get_caller_address, get_contract_address};
#[storage]
struct Storage {
token: IERC20Dispatcher,
total_supply: u256,
balance_of: LegacyMap<ContractAddress, u256>
}
#[constructor]
fn constructor(ref self: ContractState, token: ContractAddress) {
self.token.write(IERC20Dispatcher { contract_address: token });
}
#[generate_trait]
impl PrivateFunctions of PrivateFunctionsTrait {
fn _mint(ref self: ContractState, to: ContractAddress, shares: u256) {
self.total_supply.write(self.total_supply.read() + shares);
self.balance_of.write(to, self.balance_of.read(to) + shares);
}
fn _burn(ref self: ContractState, from: ContractAddress, shares: u256) {
self.total_supply.write(self.total_supply.read() - shares);
self.balance_of.write(from, self.balance_of.read(from) - shares);
}
}
#[abi(embed_v0)]
impl SimpleVault of super::ISimpleVault<ContractState> {
fn deposit(ref self: ContractState, amount: u256) {
// a = amount
// B = balance of token before deposit
// T = total supply
// s = shares to mint
//
// (T + s) / T = (a + B) / B
//
// s = aT / B
let caller = get_caller_address();
let this = get_contract_address();
let mut shares = 0;
if self.total_supply.read() == 0 {
shares = amount;
} else {
let balance = self.token.read().balance_of(this);
shares = (amount * self.total_supply.read()) / balance;
}
PrivateFunctions::_mint(ref self, caller, shares);
self.token.read().transfer_from(caller, this, amount);
}
fn withdraw(ref self: ContractState, shares: u256) {
// a = amount
// B = balance of token before withdraw
// T = total supply
// s = shares to burn
//
// (T - s) / T = (B - a) / B
//
// a = sB / T
let caller = get_caller_address();
let this = get_contract_address();
let balance = self.token.read().balance_of(this);
let amount = (shares * balance) / self.total_supply.read();
PrivateFunctions::_burn(ref self, caller, shares);
self.token.read().transfer(caller, amount);
}
}
}
Play with this contract in Remix.
ERC20 Token
Contracts that follow the ERC20 Standard are called ERC20 tokens. They are used to represent fungible assets.
To create an ERC20 conctract, it must implement the following interface:
#[starknet::interface]
trait IERC20<TContractState> {
fn get_name(self: @TContractState) -> felt252;
fn get_symbol(self: @TContractState) -> felt252;
fn get_decimals(self: @TContractState) -> u8;
fn get_total_supply(self: @TContractState) -> felt252;
fn balance_of(self: @TContractState, account: ContractAddress) -> felt252;
fn allowance(
self: @TContractState, owner: ContractAddress, spender: ContractAddress
) -> felt252;
fn transfer(ref self: TContractState, recipient: ContractAddress, amount: felt252);
fn transfer_from(
ref self: TContractState,
sender: ContractAddress,
recipient: ContractAddress,
amount: felt252
);
fn approve(ref self: TContractState, spender: ContractAddress, amount: felt252);
fn increase_allowance(ref self: TContractState, spender: ContractAddress, added_value: felt252);
fn decrease_allowance(
ref self: TContractState, spender: ContractAddress, subtracted_value: felt252
);
}
In Starknet, function names should be written in snake_case. This is not the case in Solidity, where function names are written in camelCase. The Starknet ERC20 interface is therefore slightly different from the Solidity ERC20 interface.
Here's an implementation of the ERC20 interface in Cairo:
#[starknet::contract]
mod erc20 {
use zeroable::Zeroable;
use starknet::get_caller_address;
use starknet::contract_address_const;
use starknet::ContractAddress;
#[storage]
struct Storage {
name: felt252,
symbol: felt252,
decimals: u8,
total_supply: felt252,
balances: LegacyMap::<ContractAddress, felt252>,
allowances: LegacyMap::<(ContractAddress, ContractAddress), felt252>,
}
#[event]
#[derive(Drop, starknet::Event)]
enum Event {
Transfer: Transfer,
Approval: Approval,
}
#[derive(Drop, starknet::Event)]
struct Transfer {
from: ContractAddress,
to: ContractAddress,
value: felt252,
}
#[derive(Drop, starknet::Event)]
struct Approval {
owner: ContractAddress,
spender: ContractAddress,
value: felt252,
}
mod Errors {
const APPROVE_FROM_ZERO: felt252 = 'ERC20: approve from 0';
const APPROVE_TO_ZERO: felt252 = 'ERC20: approve to 0';
const TRANSFER_FROM_ZERO: felt252 = 'ERC20: transfer from 0';
const TRANSFER_TO_ZERO: felt252 = 'ERC20: transfer to 0';
const BURN_FROM_ZERO: felt252 = 'ERC20: burn from 0';
const MINT_TO_ZERO: felt252 = 'ERC20: mint to 0';
}
#[constructor]
fn constructor(
ref self: ContractState,
recipient: ContractAddress,
name: felt252,
decimals: u8,
initial_supply: felt252,
symbol: felt252
) {
self.name.write(name);
self.symbol.write(symbol);
self.decimals.write(decimals);
self.mint(recipient, initial_supply);
}
#[abi(embed_v0)]
impl IERC20Impl of super::IERC20<ContractState> {
fn get_name(self: @ContractState) -> felt252 {
self.name.read()
}
fn get_symbol(self: @ContractState) -> felt252 {
self.symbol.read()
}
fn get_decimals(self: @ContractState) -> u8 {
self.decimals.read()
}
fn get_total_supply(self: @ContractState) -> felt252 {
self.total_supply.read()
}
fn balance_of(self: @ContractState, account: ContractAddress) -> felt252 {
self.balances.read(account)
}
fn allowance(
self: @ContractState, owner: ContractAddress, spender: ContractAddress
) -> felt252 {
self.allowances.read((owner, spender))
}
fn transfer(ref self: ContractState, recipient: ContractAddress, amount: felt252) {
let sender = get_caller_address();
self._transfer(sender, recipient, amount);
}
fn transfer_from(
ref self: ContractState,
sender: ContractAddress,
recipient: ContractAddress,
amount: felt252
) {
let caller = get_caller_address();
self.spend_allowance(sender, caller, amount);
self._transfer(sender, recipient, amount);
}
fn approve(ref self: ContractState, spender: ContractAddress, amount: felt252) {
let caller = get_caller_address();
self.approve_helper(caller, spender, amount);
}
fn increase_allowance(
ref self: ContractState, spender: ContractAddress, added_value: felt252
) {
let caller = get_caller_address();
self
.approve_helper(
caller, spender, self.allowances.read((caller, spender)) + added_value
);
}
fn decrease_allowance(
ref self: ContractState, spender: ContractAddress, subtracted_value: felt252
) {
let caller = get_caller_address();
self
.approve_helper(
caller, spender, self.allowances.read((caller, spender)) - subtracted_value
);
}
}
#[generate_trait]
impl InternalImpl of InternalTrait {
fn _transfer(
ref self: ContractState,
sender: ContractAddress,
recipient: ContractAddress,
amount: felt252
) {
assert(!sender.is_zero(), Errors::TRANSFER_FROM_ZERO);
assert(!recipient.is_zero(), Errors::TRANSFER_TO_ZERO);
self.balances.write(sender, self.balances.read(sender) - amount);
self.balances.write(recipient, self.balances.read(recipient) + amount);
self.emit(Transfer { from: sender, to: recipient, value: amount });
}
fn spend_allowance(
ref self: ContractState,
owner: ContractAddress,
spender: ContractAddress,
amount: felt252
) {
let allowance = self.allowances.read((owner, spender));
self.allowances.write((owner, spender), allowance - amount);
}
fn approve_helper(
ref self: ContractState,
owner: ContractAddress,
spender: ContractAddress,
amount: felt252
) {
assert(!spender.is_zero(), Errors::APPROVE_TO_ZERO);
self.allowances.write((owner, spender), amount);
self.emit(Approval { owner, spender, value: amount });
}
fn mint(ref self: ContractState, recipient: ContractAddress, amount: felt252) {
assert(!recipient.is_zero(), Errors::MINT_TO_ZERO);
let supply = self.total_supply.read() + amount; // What can go wrong here?
self.total_supply.write(supply);
let balance = self.balances.read(recipient) + amount;
self.balances.write(recipient, amount);
self
.emit(
Event::Transfer(
Transfer {
from: contract_address_const::<0>(), to: recipient, value: amount
}
)
);
}
}
}
Play with this contract in Remix.
There's several other implementations, such as the Open Zeppelin or the Cairo By Example ones.
Constant Product AMM
This is the Cairo adaptation of the Solidity by example Constant Product AMM.
use starknet::ContractAddress;
#[starknet::interface]
trait IConstantProductAmm<TContractState> {
fn swap(ref self: TContractState, token_in: ContractAddress, amount_in: u256) -> u256;
fn add_liquidity(ref self: TContractState, amount0: u256, amount1: u256) -> u256;
fn remove_liquidity(ref self: TContractState, shares: u256) -> (u256, u256);
}
#[starknet::contract]
mod ConstantProductAmm {
use core::traits::Into;
use openzeppelin::token::erc20::interface::{IERC20Dispatcher, IERC20DispatcherTrait};
use starknet::{
ContractAddress, get_caller_address, get_contract_address, contract_address_const
};
use integer::u256_sqrt;
#[storage]
struct Storage {
token0: IERC20Dispatcher,
token1: IERC20Dispatcher,
reserve0: u256,
reserve1: u256,
total_supply: u256,
balance_of: LegacyMap::<ContractAddress, u256>,
// Fee 0 - 1000 (0% - 100%, 1 decimal places)
// E.g. 3 = 0.3%
fee: u16,
}
#[constructor]
fn constructor(
ref self: ContractState, token0: ContractAddress, token1: ContractAddress, fee: u16
) {
// assert(fee <= 1000, 'fee > 1000');
self.token0.write(IERC20Dispatcher { contract_address: token0 });
self.token1.write(IERC20Dispatcher { contract_address: token1 });
self.fee.write(fee);
}
#[generate_trait]
impl PrivateFunctions of PrivateFunctionsTrait {
fn _mint(ref self: ContractState, to: ContractAddress, amount: u256) {
self.balance_of.write(to, self.balance_of.read(to) + amount);
self.total_supply.write(self.total_supply.read() + amount);
}
fn _burn(ref self: ContractState, from: ContractAddress, amount: u256) {
self.balance_of.write(from, self.balance_of.read(from) - amount);
self.total_supply.write(self.total_supply.read() - amount);
}
fn _update(ref self: ContractState, reserve0: u256, reserve1: u256) {
self.reserve0.write(reserve0);
self.reserve1.write(reserve1);
}
#[inline(always)]
fn select_token(self: @ContractState, token: ContractAddress) -> bool {
assert(
token == self.token0.read().contract_address
|| token == self.token1.read().contract_address,
'invalid token'
);
token == self.token0.read().contract_address
}
#[inline(always)]
fn min(x: u256, y: u256) -> u256 {
if (x <= y) {
x
} else {
y
}
}
}
#[abi(embed_v0)]
impl ConstantProductAmm of super::IConstantProductAmm<ContractState> {
fn swap(ref self: ContractState, token_in: ContractAddress, amount_in: u256) -> u256 {
assert(amount_in > 0, 'amount in = 0');
let is_token0: bool = self.select_token(token_in);
let (token0, token1): (IERC20Dispatcher, IERC20Dispatcher) = (
self.token0.read(), self.token1.read()
);
let (reserve0, reserve1): (u256, u256) = (self.reserve0.read(), self.reserve1.read());
let (
token_in, token_out, reserve_in, reserve_out
): (IERC20Dispatcher, IERC20Dispatcher, u256, u256) =
if (is_token0) {
(token0, token1, reserve0, reserve1)
} else {
(token1, token0, reserve1, reserve0)
};
let caller = get_caller_address();
let this = get_contract_address();
token_in.transfer_from(caller, this, amount_in);
// How much dy for dx?
// xy = k
// (x + dx)(y - dy) = k
// y - dy = k / (x + dx)
// y - k / (x + dx) = dy
// y - xy / (x + dx) = dy
// (yx + ydx - xy) / (x + dx) = dy
// ydx / (x + dx) = dy
let amount_in_with_fee = (amount_in * (1000 - self.fee.read().into()) / 1000);
let amount_out = (reserve_out * amount_in_with_fee) / (reserve_in + amount_in_with_fee);
token_out.transfer(caller, amount_out);
self._update(self.token0.read().balance_of(this), self.token1.read().balance_of(this));
amount_out
}
fn add_liquidity(ref self: ContractState, amount0: u256, amount1: u256) -> u256 {
let caller = get_caller_address();
let this = get_contract_address();
let (token0, token1): (IERC20Dispatcher, IERC20Dispatcher) = (
self.token0.read(), self.token1.read()
);
token0.transfer_from(caller, this, amount0);
token1.transfer_from(caller, this, amount1);
// How much dx, dy to add?
//
// xy = k
// (x + dx)(y + dy) = k'
//
// No price change, before and after adding liquidity
// x / y = (x + dx) / (y + dy)
//
// x(y + dy) = y(x + dx)
// x * dy = y * dx
//
// x / y = dx / dy
// dy = y / x * dx
let (reserve0, reserve1): (u256, u256) = (self.reserve0.read(), self.reserve1.read());
if (reserve0 > 0 || reserve1 > 0) {
assert(reserve0 * amount1 == reserve1 * amount0, 'x / y != dx / dy');
}
// How much shares to mint?
//
// f(x, y) = value of liquidity
// We will define f(x, y) = sqrt(xy)
//
// L0 = f(x, y)
// L1 = f(x + dx, y + dy)
// T = total shares
// s = shares to mint
//
// Total shares should increase proportional to increase in liquidity
// L1 / L0 = (T + s) / T
//
// L1 * T = L0 * (T + s)
//
// (L1 - L0) * T / L0 = s
// Claim
// (L1 - L0) / L0 = dx / x = dy / y
//
// Proof
// --- Equation 1 ---
// (L1 - L0) / L0 = (sqrt((x + dx)(y + dy)) - sqrt(xy)) / sqrt(xy)
//
// dx / dy = x / y so replace dy = dx * y / x
//
// --- Equation 2 ---
// Equation 1 = (sqrt(xy + 2ydx + dx^2 * y / x) - sqrt(xy)) / sqrt(xy)
//
// Multiply by sqrt(x) / sqrt(x)
// Equation 2 = (sqrt(x^2y + 2xydx + dx^2 * y) - sqrt(x^2y)) / sqrt(x^2y)
// = (sqrt(y)(sqrt(x^2 + 2xdx + dx^2) - sqrt(x^2)) / (sqrt(y)sqrt(x^2))
// sqrt(y) on top and bottom cancels out
//
// --- Equation 3 ---
// Equation 2 = (sqrt(x^2 + 2xdx + dx^2) - sqrt(x^2)) / (sqrt(x^2)
// = (sqrt((x + dx)^2) - sqrt(x^2)) / sqrt(x^2)
// = ((x + dx) - x) / x
// = dx / x
// Since dx / dy = x / y,
// dx / x = dy / y
//
// Finally
// (L1 - L0) / L0 = dx / x = dy / y
let total_supply = self.total_supply.read();
let shares = if (total_supply == 0) {
u256_sqrt(amount0 * amount1).into()
} else {
PrivateFunctions::min(
amount0 * total_supply / reserve0, amount1 * total_supply / reserve1
)
};
assert(shares > 0, 'shares = 0');
self._mint(caller, shares);
self._update(self.token0.read().balance_of(this), self.token1.read().balance_of(this));
shares
}
fn remove_liquidity(ref self: ContractState, shares: u256) -> (u256, u256) {
let caller = get_caller_address();
let this = get_contract_address();
let (token0, token1): (IERC20Dispatcher, IERC20Dispatcher) = (
self.token0.read(), self.token1.read()
);
// Claim
// dx, dy = amount of liquidity to remove
// dx = s / T * x
// dy = s / T * y
//
// Proof
// Let's find dx, dy such that
// v / L = s / T
//
// where
// v = f(dx, dy) = sqrt(dxdy)
// L = total liquidity = sqrt(xy)
// s = shares
// T = total supply
//
// --- Equation 1 ---
// v = s / T * L
// sqrt(dxdy) = s / T * sqrt(xy)
//
// Amount of liquidity to remove must not change price so
// dx / dy = x / y
//
// replace dy = dx * y / x
// sqrt(dxdy) = sqrt(dx * dx * y / x) = dx * sqrt(y / x)
//
// Divide both sides of Equation 1 with sqrt(y / x)
// dx = s / T * sqrt(xy) / sqrt(y / x)
// = s / T * sqrt(x^2) = s / T * x
//
// Likewise
// dy = s / T * y
// bal0 >= reserve0
// bal1 >= reserve1
let (bal0, bal1): (u256, u256) = (token0.balance_of(this), token1.balance_of(this));
let total_supply = self.total_supply.read();
let (amount0, amount1): (u256, u256) = (
(shares * bal0) / total_supply, (shares * bal1) / total_supply
);
assert(amount0 > 0 && amount1 > 0, 'amount0 or amount1 = 0');
self._burn(caller, shares);
self._update(bal0 - amount0, bal1 - amount1);
token0.transfer(caller, amount0);
token1.transfer(caller, amount1);
(amount0, amount1)
}
}
}
Play with this contract in Remix.
Writing to any storage slot
On Starknet, a contract's storage is a map with 2^251 slots, where each slot is a felt which is initialized to 0.
The address of storage variables is computed at compile time using the formula: storage variable address := pedersen(keccak(variable name), keys)
. Interactions with storage variables are commonly performed using the self.var.read()
and self.var.write()
functions.
Nevertheless, we can use the storage_write_syscall
and storage_read_syscall
syscalls, to write to and read from any storage slot.
This is useful when writing to storage variables that are not known at compile time, or to ensure that even if the contract is upgraded and the computation method of storage variable addresses changes, they remain accessible.
In the following example, we use the Poseidon hash function to compute the address of a storage variable. Poseidon is a ZK-friendly hash function that is cheaper and faster than Pedersen, making it an excellent choice for onchain computations. Once the address is computed, we use the storage syscalls to interact with it.
#[starknet::interface]
trait IWriteToAnySlots<TContractState> {
fn write_slot(ref self: TContractState, value: u32);
fn read_slot(self: @TContractState) -> u32;
}
#[starknet::contract]
mod WriteToAnySlot {
use starknet::syscalls::{storage_read_syscall, storage_write_syscall};
use starknet::SyscallResultTrait;
use poseidon::poseidon_hash_span;
use starknet::storage_access::Felt252TryIntoStorageAddress;
use starknet::StorageAddress;
#[storage]
struct Storage {}
const SLOT_NAME: felt252 = 'test_slot';
#[abi(embed_v0)]
impl WriteToAnySlot of super::IWriteToAnySlots<ContractState> {
fn write_slot(ref self: ContractState, value: u32) {
storage_write_syscall(0, get_address_from_name(SLOT_NAME), value.into());
}
fn read_slot(self: @ContractState) -> u32 {
storage_read_syscall(0, get_address_from_name(SLOT_NAME))
.unwrap_syscall()
.try_into()
.unwrap()
}
}
fn get_address_from_name(variable_name: felt252) -> StorageAddress {
let mut data: Array<felt252> = ArrayTrait::new();
data.append(variable_name);
let hashed_name: felt252 = poseidon_hash_span(data.span());
let MASK_250: u256 = 0x03ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff;
// By taking the 250 least significant bits of the hash output, we get a valid 250bits storage address.
let result: felt252 = (hashed_name.into() & MASK_250).try_into().unwrap();
let result: StorageAddress = result.try_into().unwrap();
result
}
}
Visit contract on Voyager or play with it in Remix.
Storing Arrays
On Starknet, complex values (e.g., tuples or structs), are stored in a continuous segment starting from the address of the storage variable. There is a 256 field elements limitation to the maximal size of a complex storage value, meaning that to store arrays of more than 255 elements in storage, we would need to split it into segments of size n <= 255
and store these segments in multiple storage addresses. There is currently no native support for storing arrays in Cairo, so you will need to write your own implementation of the Store
trait for the type of array you wish to store.
Note: While storing arrays in storage is possible, it is not always recommended, as the read and write operations can get very costly. For example, reading an array of size
n
requiresn
storage reads, and writing to an array of sizen
requiresn
storage writes. If you only need to access a single element of the array at a time, it is recommended to use aLegacyMap
and store the length in another variable instead.
The following example demonstrates how to write a simple implementation of the StorageAccess
trait for the Array<felt252>
type, allowing us to store arrays of up to 255 felt252
elements.
impl StoreFelt252Array of Store<Array<felt252>> {
fn read(address_domain: u32, base: StorageBaseAddress) -> SyscallResult<Array<felt252>> {
StoreFelt252Array::read_at_offset(address_domain, base, 0)
}
fn write(
address_domain: u32, base: StorageBaseAddress, value: Array<felt252>
) -> SyscallResult<()> {
StoreFelt252Array::write_at_offset(address_domain, base, 0, value)
}
fn read_at_offset(
address_domain: u32, base: StorageBaseAddress, mut offset: u8
) -> SyscallResult<Array<felt252>> {
let mut arr: Array<felt252> = ArrayTrait::new();
// Read the stored array's length. If the length is superior to 255, the read will fail.
let len: u8 = Store::<u8>::read_at_offset(address_domain, base, offset)
.expect('Storage Span too large');
offset += 1;
// Sequentially read all stored elements and append them to the array.
let exit = len + offset;
loop {
if offset >= exit {
break;
}
let value = Store::<felt252>::read_at_offset(address_domain, base, offset).unwrap();
arr.append(value);
offset += Store::<felt252>::size();
};
// Return the array.
Result::Ok(arr)
}
fn write_at_offset(
address_domain: u32, base: StorageBaseAddress, mut offset: u8, mut value: Array<felt252>
) -> SyscallResult<()> {
// // Store the length of the array in the first storage slot.
let len: u8 = value.len().try_into().expect('Storage - Span too large');
Store::<u8>::write_at_offset(address_domain, base, offset, len);
offset += 1;
// Store the array elements sequentially
loop {
match value.pop_front() {
Option::Some(element) => {
Store::<felt252>::write_at_offset(address_domain, base, offset, element);
offset += Store::<felt252>::size();
},
Option::None(_) => { break Result::Ok(()); }
};
}
}
fn size() -> u8 {
255 * Store::<felt252>::size()
}
}
You can then import this implementation in your contract and use it to store arrays in storage:
#[starknet::interface]
trait IStoreArrayContract<TContractState> {
fn store_array(ref self: TContractState, arr: Array<felt252>);
fn read_array(self: @TContractState) -> Array<felt252>;
}
#[starknet::contract]
mod StoreArrayContract {
use super::StoreFelt252Array;
#[storage]
struct Storage {
arr: Array<felt252>
}
#[abi(embed_v0)]
impl StoreArrayImpl of super::IStoreArrayContract<ContractState> {
fn store_array(ref self: ContractState, arr: Array<felt252>) {
self.arr.write(arr);
}
fn read_array(self: @ContractState) -> Array<felt252> {
self.arr.read()
}
}
}
Visit contract on Voyager or play with it in Remix.
Structs as mapping keys
In order to use structs as mapping keys, you can use #[derive(Hash)]
on the struct definition. This will automatically generate a hash function for the struct that can be used to represent the struct as a key in a LegacyMap
.
Consider the following example in which we would like to use an object of
type Pet
as a key in a LegacyMap
. The Pet
struct has three fields: name
, age
and owner
. We consider that the combination of these three fields uniquely identifies a pet.
#[derive(Copy, Drop, Serde, Hash)]
struct Pet {
name: felt252,
age: u8,
owner: felt252,
}
#[starknet::interface]
trait IPetRegistry<TContractState> {
fn register_pet(ref self: TContractState, key: Pet, timestamp: u64);
fn get_registration_date(self: @TContractState, key: Pet) -> u64;
}
#[starknet::contract]
mod PetRegistry {
use hash::{HashStateTrait, Hash};
use super::Pet;
#[storage]
struct Storage {
registration_time: LegacyMap::<Pet, u64>,
}
#[abi(embed_v0)]
impl PetRegistry of super::IPetRegistry<ContractState> {
fn register_pet(ref self: ContractState, key: Pet, timestamp: u64) {
self.registration_time.write(key, timestamp);
}
fn get_registration_date(self: @ContractState, key: Pet) -> u64 {
self.registration_time.read(key)
}
}
}
Play with this contract in Remix.
Hash Solidity Compatible
This contract demonstrates Keccak hashing in Cairo to match Solidity's keccak256. While both use Keccak, their endianness differs: Cairo is little-endian, Solidity big-endian. The contract achieves compatibility by hashing in big-endian using keccak_u256s_be_inputs
, and reversing the bytes of the result with u128_byte_reverse
.
For example:
#[starknet::interface]
trait ISolidityHashExample<TContractState> {
fn hash_data(ref self: TContractState, input_data: Span<u256>) -> u256;
}
#[starknet::contract]
mod SolidityHashExample {
use keccak::{keccak_u256s_be_inputs};
use array::Span;
#[storage]
struct Storage {}
#[abi(embed_v0)]
impl SolidityHashExample of super::ISolidityHashExample<ContractState> {
fn hash_data(ref self: ContractState, input_data: Span<u256>) -> u256 {
let hashed = keccak_u256s_be_inputs(input_data);
// Split the hashed value into two 128-bit segments
let low: u128 = hashed.low;
let high: u128 = hashed.high;
// Reverse each 128-bit segment
let reversed_low = integer::u128_byte_reverse(low);
let reversed_high = integer::u128_byte_reverse(high);
// Reverse merge the reversed segments back into a u256 value
let compatible_hash = u256 { low: reversed_high, high: reversed_low };
compatible_hash
}
}
}
Play with the contract in Remix.
Optimisations
A collection of optimisation patterns to save gas and steps.
Storage optimisation
A smart contract has a limited amount of storage slots. Each slot can store a single felt252
value.
Writing to a storage slot has a cost, so we want to use as few storage slots as possible.
In Cairo, every type is derived from the felt252
type, which uses 252 bits to store a value.
This design is quite simple, but it does have a drawback: it is not storage efficient. For example, if we want to store a u8
value, we need to use an entire slot, even though we only need 8 bits.
Packing
When storing multiple values, we can use a technique called packing. Packing is a technique that allows us to store multiple values in a single felt value. This is done by using the bits of the felt value to store multiple values.
For example, if we want to store two u8
values, we can use the first 8 bits of the felt value to store the first u8
value, and the last 8 bits to store the second u8
value. This way, we can store two u8
values in a single felt value.
Cairo provides a built-in store using packing that you can use with the StorePacking
trait.
trait StorePacking<T, PackedT> {
fn pack(value: T) -> PackedT;
fn unpack(value: PackedT) -> T;
}
This allows to store the type T
by first packing it into the type PackedT
with the pack
function, and then storing the PackedT
value with it's Store
implementation. When reading the value, we first retrieve the PackedT
value, and then unpack it into the type T
using the unpack
function.
Here's an example of storing a Time
struct with two u8
values using the StorePacking
trait:
#[starknet::interface]
trait ITime<TContractState> {
fn set(ref self: TContractState, value: TimeContract::Time);
fn get(self: @TContractState) -> TimeContract::Time;
}
#[starknet::contract]
mod TimeContract {
use starknet::storage_access::StorePacking;
use integer::{
U8IntoFelt252, Felt252TryIntoU16, U16DivRem, u16_as_non_zero, U16IntoFelt252,
Felt252TryIntoU8
};
use traits::{Into, TryInto, DivRem};
use option::OptionTrait;
use serde::Serde;
#[storage]
struct Storage {
time: Time
}
#[derive(Copy, Serde, Drop)]
struct Time {
hour: u8,
minute: u8
}
impl TimePackable of StorePacking<Time, felt252> {
fn pack(value: Time) -> felt252 {
let msb: felt252 = 256 * value.hour.into();
let lsb: felt252 = value.minute.into();
return msb + lsb;
}
fn unpack(value: felt252) -> Time {
let value: u16 = value.try_into().unwrap();
let (q, r) = U16DivRem::div_rem(value, u16_as_non_zero(256));
let hour: u8 = Into::<u16, felt252>::into(q).try_into().unwrap();
let minute: u8 = Into::<u16, felt252>::into(r).try_into().unwrap();
return Time { hour, minute };
}
}
#[abi(embed_v0)]
impl TimeContract of super::ITime<ContractState> {
fn set(ref self: ContractState, value: Time) {
// This will call the pack method of the TimePackable trait
// and store the resulting felt252
self.time.write(value);
}
fn get(self: @ContractState) -> Time {
// This will read the felt252 value from storage
// and return the result of the unpack method of the TimePackable trait
return self.time.read();
}
}
}
Play with this contract in Remix.
List
By default, there is no list type supported in Cairo, but you can use Alexandria. You can refer to the Alexandria documentation for more details.
What is List
?
An ordered sequence of values that can be used in Starknet storage:
#[storage]
stuct Storage {
amounts: List<u128>
}
Interface
trait ListTrait<T> {
fn len(self: @List<T>) -> u32;
fn is_empty(self: @List<T>) -> bool;
fn append(ref self: List<T>, value: T) -> u32;
fn get(self: @List<T>, index: u32) -> Option<T>;
fn set(ref self: List<T>, index: u32, value: T);
fn pop_front(ref self: List<T>) -> Option<T>;
fn array(self: @List<T>) -> Array<T>;
}
List
also implements IndexView
so you can use the familiar bracket notation to access its members:
let second = self.amounts.read()[1];
Note that unlike get
, using this bracket notation panics when accessing an out of bounds index.
Support for custom types
List
supports most of the corelib types out of the box. If you want to store a your own custom type in a List
, it has to implement the Store
trait. You can have the compiler derive it for you using the #[derive(starknet::Store)]
attribute.
Caveats
There are two idiosyncacies you should be aware of when using List
- The
append
operation costs 2 storage writes - one for the value itself and another one for updating the List's length - Due to a compiler limitation, it is not possible to use mutating operations with a single inline statement. For example,
self.amounts.read().append(42);
will not work. You have to do it in 2 steps:
let mut amounts = self.amounts.read();
amounts.append(42);
Dependencies
Update your project dependencies by in the Scarb.toml
file:
[dependencies]
(...)
alexandria_storage = { git = "https://github.com/keep-starknet-strange/alexandria.git" }
For example, let's use List
to create a contract that tracks a list of amounts and tasks:
#[starknet::interface]
trait IListExample<TContractState> {
fn add_in_amount(ref self: TContractState, number: u128);
fn add_in_task(ref self: TContractState, description: felt252, status: felt252);
fn is_empty_list(self: @TContractState) -> bool;
fn list_length(self: @TContractState) -> u32;
fn get_from_index(self: @TContractState, index: u32) -> u128;
fn set_from_index(ref self: TContractState, index: u32, number: u128);
fn pop_front_list(ref self: TContractState);
fn array_conversion(self: @TContractState) -> Array<u128>;
}
#[starknet::contract]
mod ListExample {
use alexandria_storage::list::{List, ListTrait};
#[storage]
struct Storage {
amount: List<u128>,
tasks: List<Task>
}
#[derive(Copy, Drop, Serde, starknet::Store)]
struct Task {
description: felt252,
status: felt252
}
#[abi(embed_v0)]
impl ListExample of super::IListExample<ContractState> {
fn add_in_amount(ref self: ContractState, number: u128) {
let mut current_amount_list = self.amount.read();
current_amount_list.append(number);
}
fn add_in_task(ref self: ContractState, description: felt252, status: felt252) {
let new_task = Task { description: description, status: status };
let mut current_tasks_list = self.tasks.read();
current_tasks_list.append(new_task);
}
fn is_empty_list(self: @ContractState) -> bool {
let mut current_amount_list = self.amount.read();
current_amount_list.is_empty()
}
fn list_length(self: @ContractState) -> u32 {
let mut current_amount_list = self.amount.read();
current_amount_list.len()
}
fn get_from_index(self: @ContractState, index: u32) -> u128 {
self.amount.read()[index]
}
fn set_from_index(ref self: ContractState, index: u32, number: u128) {
let mut current_amount_list = self.amount.read();
current_amount_list.set(index, number);
}
fn pop_front_list(ref self: ContractState) {
let mut current_amount_list = self.amount.read();
current_amount_list.pop_front();
}
fn array_conversion(self: @ContractState) -> Array<u128> {
let mut current_amount_list = self.amount.read();
current_amount_list.array()
}
}
}