Contract Storage
The contract’s storage is a persistent storage space where you can read, write,
modify, and persist data. The storage is a map with \(2^{251}\) slots, where
each slot is a felt252 initialized to 0.
Each storage slot is identified by a felt252 value, called the storage
address, which is computed from the variable's name and parameters that depend
on the variable's type, outlined in the "Addresses of Storage
Variables" section.
We can interact with the contract's storage in two ways:
- Through high-level storage variables, which are declared in a special
Storagestruct annotated with the#[storage]attribute. - Directly accessing storage slots using their computed address and the
low-level
storage_readandstorage_writesyscalls. This is useful when you need to perform custom storage operations that don't fit well with the structured approach of storage variables, but should generally be avoided; as such, we will not cover them in this chapter.
Declaring and Using Storage Variables
Storage variables in Starknet contracts are stored in a special struct called
Storage:
#[starknet::interface]
pub trait ISimpleStorage<TContractState> {
fn get_owner(self: @TContractState) -> SimpleStorage::Person;
fn get_owner_name(self: @TContractState) -> felt252;
fn get_expiration(self: @TContractState) -> SimpleStorage::Expiration;
fn change_expiration(ref self: TContractState, expiration: SimpleStorage::Expiration);
}
#[starknet::contract]
mod SimpleStorage {
use starknet::storage::{StoragePointerReadAccess, StoragePointerWriteAccess};
use starknet::{ContractAddress, get_caller_address};
#[storage]
struct Storage {
owner: Person,
expiration: Expiration,
}
#[derive(Drop, Serde, starknet::Store)]
pub struct Person {
address: ContractAddress,
name: felt252,
}
#[derive(Copy, Drop, Serde, starknet::Store)]
pub enum Expiration {
Finite: u64,
#[default]
Infinite,
}
#[constructor]
fn constructor(ref self: ContractState, owner: Person) {
self.owner.write(owner);
}
#[abi(embed_v0)]
impl SimpleCounterImpl of super::ISimpleStorage<ContractState> {
fn get_owner(self: @ContractState) -> Person {
self.owner.read()
}
fn get_owner_name(self: @ContractState) -> felt252 {
self.owner.name.read()
}
fn get_expiration(self: @ContractState) -> Expiration {
self.expiration.read()
}
fn change_expiration(ref self: ContractState, expiration: Expiration) {
if get_caller_address() != self.owner.address.read() {
panic!("Only the owner can change the expiration");
}
self.expiration.write(expiration);
}
}
fn get_owner_storage_address(self: @ContractState) -> felt252 {
self.owner.__base_address__
}
fn get_owner_name_storage_address(self: @ContractState) -> felt252 {
self.owner.name.__storage_pointer_address__.into()
}
}
#[cfg(test)]
mod tests;
The Storage struct is a struct like any other, except that it
must be annotated with the #[storage] attribute. This annotation tells the
compiler to generate the required code to interact with the blockchain state,
and allows you to read and write data from and to storage. This struct can
contain any type that implements the Store trait, including other structs,
enums, as well as Storage Mappings, Storage
Vectors, and Storage Nodes. In this section,
we'll focus on simple storage variables, and we'll see how to store more complex
types in the next sections.
Accessing Storage Variables
Variables stored in the Storage struct can be accessed and modified using the
read and write functions, respectively. All these functions are
automatically generated by the compiler for each storage variable.
To read the value of the owner storage variable, which is of type Person, we
call the read function on the owner variable, passing in no arguments.
#[starknet::interface]
pub trait ISimpleStorage<TContractState> {
fn get_owner(self: @TContractState) -> SimpleStorage::Person;
fn get_owner_name(self: @TContractState) -> felt252;
fn get_expiration(self: @TContractState) -> SimpleStorage::Expiration;
fn change_expiration(ref self: TContractState, expiration: SimpleStorage::Expiration);
}
#[starknet::contract]
mod SimpleStorage {
use starknet::storage::{StoragePointerReadAccess, StoragePointerWriteAccess};
use starknet::{ContractAddress, get_caller_address};
#[storage]
struct Storage {
owner: Person,
expiration: Expiration,
}
#[derive(Drop, Serde, starknet::Store)]
pub struct Person {
address: ContractAddress,
name: felt252,
}
#[derive(Copy, Drop, Serde, starknet::Store)]
pub enum Expiration {
Finite: u64,
#[default]
Infinite,
}
#[constructor]
fn constructor(ref self: ContractState, owner: Person) {
self.owner.write(owner);
}
#[abi(embed_v0)]
impl SimpleCounterImpl of super::ISimpleStorage<ContractState> {
fn get_owner(self: @ContractState) -> Person {
self.owner.read()
}
fn get_owner_name(self: @ContractState) -> felt252 {
self.owner.name.read()
}
fn get_expiration(self: @ContractState) -> Expiration {
self.expiration.read()
}
fn change_expiration(ref self: ContractState, expiration: Expiration) {
if get_caller_address() != self.owner.address.read() {
panic!("Only the owner can change the expiration");
}
self.expiration.write(expiration);
}
}
fn get_owner_storage_address(self: @ContractState) -> felt252 {
self.owner.__base_address__
}
fn get_owner_name_storage_address(self: @ContractState) -> felt252 {
self.owner.name.__storage_pointer_address__.into()
}
}
#[cfg(test)]
mod tests;
To write a new value to the storage slot of a storage variable, we call the
write function, passing in the value as argument. Here, we only pass in the
value to write to the owner variable as it is a simple variable.
#[starknet::interface]
pub trait ISimpleStorage<TContractState> {
fn get_owner(self: @TContractState) -> SimpleStorage::Person;
fn get_owner_name(self: @TContractState) -> felt252;
fn get_expiration(self: @TContractState) -> SimpleStorage::Expiration;
fn change_expiration(ref self: TContractState, expiration: SimpleStorage::Expiration);
}
#[starknet::contract]
mod SimpleStorage {
use starknet::storage::{StoragePointerReadAccess, StoragePointerWriteAccess};
use starknet::{ContractAddress, get_caller_address};
#[storage]
struct Storage {
owner: Person,
expiration: Expiration,
}
#[derive(Drop, Serde, starknet::Store)]
pub struct Person {
address: ContractAddress,
name: felt252,
}
#[derive(Copy, Drop, Serde, starknet::Store)]
pub enum Expiration {
Finite: u64,
#[default]
Infinite,
}
#[constructor]
fn constructor(ref self: ContractState, owner: Person) {
self.owner.write(owner);
}
#[abi(embed_v0)]
impl SimpleCounterImpl of super::ISimpleStorage<ContractState> {
fn get_owner(self: @ContractState) -> Person {
self.owner.read()
}
fn get_owner_name(self: @ContractState) -> felt252 {
self.owner.name.read()
}
fn get_expiration(self: @ContractState) -> Expiration {
self.expiration.read()
}
fn change_expiration(ref self: ContractState, expiration: Expiration) {
if get_caller_address() != self.owner.address.read() {
panic!("Only the owner can change the expiration");
}
self.expiration.write(expiration);
}
}
fn get_owner_storage_address(self: @ContractState) -> felt252 {
self.owner.__base_address__
}
fn get_owner_name_storage_address(self: @ContractState) -> felt252 {
self.owner.name.__storage_pointer_address__.into()
}
}
#[cfg(test)]
mod tests;
When working with compound types, instead of calling read and write on the
struct variable itself, which would perform a storage operation for each member,
you can call read and write on specific members of the struct. This allows
you to access and modify the values of the struct members directly, minimizing
the amount of storage operations performed. In the following example, the
owner variable is of type Person. Thus, it has one attribute called name,
on which we can call the read and write functions to access and modify its
value.
#[starknet::interface]
pub trait ISimpleStorage<TContractState> {
fn get_owner(self: @TContractState) -> SimpleStorage::Person;
fn get_owner_name(self: @TContractState) -> felt252;
fn get_expiration(self: @TContractState) -> SimpleStorage::Expiration;
fn change_expiration(ref self: TContractState, expiration: SimpleStorage::Expiration);
}
#[starknet::contract]
mod SimpleStorage {
use starknet::storage::{StoragePointerReadAccess, StoragePointerWriteAccess};
use starknet::{ContractAddress, get_caller_address};
#[storage]
struct Storage {
owner: Person,
expiration: Expiration,
}
#[derive(Drop, Serde, starknet::Store)]
pub struct Person {
address: ContractAddress,
name: felt252,
}
#[derive(Copy, Drop, Serde, starknet::Store)]
pub enum Expiration {
Finite: u64,
#[default]
Infinite,
}
#[constructor]
fn constructor(ref self: ContractState, owner: Person) {
self.owner.write(owner);
}
#[abi(embed_v0)]
impl SimpleCounterImpl of super::ISimpleStorage<ContractState> {
fn get_owner(self: @ContractState) -> Person {
self.owner.read()
}
fn get_owner_name(self: @ContractState) -> felt252 {
self.owner.name.read()
}
fn get_expiration(self: @ContractState) -> Expiration {
self.expiration.read()
}
fn change_expiration(ref self: ContractState, expiration: Expiration) {
if get_caller_address() != self.owner.address.read() {
panic!("Only the owner can change the expiration");
}
self.expiration.write(expiration);
}
}
fn get_owner_storage_address(self: @ContractState) -> felt252 {
self.owner.__base_address__
}
fn get_owner_name_storage_address(self: @ContractState) -> felt252 {
self.owner.name.__storage_pointer_address__.into()
}
}
#[cfg(test)]
mod tests;
Storing Custom Types with the Store Trait
The Store trait, defined in the starknet::storage_access module, is used to
specify how a type should be stored in storage. In order for a type to be stored
in storage, it must implement the Store trait. Most types from the core
library, such as unsigned integers (u8, u128, u256...), felt252, bool,
ByteArray, ContractAddress, etc. implement the Store trait and can thus be
stored without further action. However, memory collections, such as
Array<T> and Felt252Dict<T>, cannot be stored in contract storage - you
will have to use the special types Vec<T> and Map<K, V> instead.
But what if you wanted to store a type that you defined yourself, such as an enum or a struct? In that case, you have to explicitly tell the compiler how to store this type.
In our example, we want to store a Person struct in storage, which is only
possible by implementing the Store trait for the Person type. This can be
simply achieved by adding a #[derive(starknet::Store)] attribute on top of our
struct definition. Note that all the members of the struct need to implement the
Store trait for the trait to be derived.
#[starknet::interface]
pub trait ISimpleStorage<TContractState> {
fn get_owner(self: @TContractState) -> SimpleStorage::Person;
fn get_owner_name(self: @TContractState) -> felt252;
fn get_expiration(self: @TContractState) -> SimpleStorage::Expiration;
fn change_expiration(ref self: TContractState, expiration: SimpleStorage::Expiration);
}
#[starknet::contract]
mod SimpleStorage {
use starknet::storage::{StoragePointerReadAccess, StoragePointerWriteAccess};
use starknet::{ContractAddress, get_caller_address};
#[storage]
struct Storage {
owner: Person,
expiration: Expiration,
}
#[derive(Drop, Serde, starknet::Store)]
pub struct Person {
address: ContractAddress,
name: felt252,
}
#[derive(Copy, Drop, Serde, starknet::Store)]
pub enum Expiration {
Finite: u64,
#[default]
Infinite,
}
#[constructor]
fn constructor(ref self: ContractState, owner: Person) {
self.owner.write(owner);
}
#[abi(embed_v0)]
impl SimpleCounterImpl of super::ISimpleStorage<ContractState> {
fn get_owner(self: @ContractState) -> Person {
self.owner.read()
}
fn get_owner_name(self: @ContractState) -> felt252 {
self.owner.name.read()
}
fn get_expiration(self: @ContractState) -> Expiration {
self.expiration.read()
}
fn change_expiration(ref self: ContractState, expiration: Expiration) {
if get_caller_address() != self.owner.address.read() {
panic!("Only the owner can change the expiration");
}
self.expiration.write(expiration);
}
}
fn get_owner_storage_address(self: @ContractState) -> felt252 {
self.owner.__base_address__
}
fn get_owner_name_storage_address(self: @ContractState) -> felt252 {
self.owner.name.__storage_pointer_address__.into()
}
}
#[cfg(test)]
mod tests;
Similarly, Enums can only be written to storage if they implement the Store
trait, which can be trivially derived as long as all associated types implement
the Store trait.
Enums used in contract storage must define a default variant. This default variant is returned when reading an empty storage slot - otherwise, it will result in a runtime error.
Here's an example of how to properly define an enum for use in contract storage:
#[starknet::interface]
pub trait ISimpleStorage<TContractState> {
fn get_owner(self: @TContractState) -> SimpleStorage::Person;
fn get_owner_name(self: @TContractState) -> felt252;
fn get_expiration(self: @TContractState) -> SimpleStorage::Expiration;
fn change_expiration(ref self: TContractState, expiration: SimpleStorage::Expiration);
}
#[starknet::contract]
mod SimpleStorage {
use starknet::storage::{StoragePointerReadAccess, StoragePointerWriteAccess};
use starknet::{ContractAddress, get_caller_address};
#[storage]
struct Storage {
owner: Person,
expiration: Expiration,
}
#[derive(Drop, Serde, starknet::Store)]
pub struct Person {
address: ContractAddress,
name: felt252,
}
#[derive(Copy, Drop, Serde, starknet::Store)]
pub enum Expiration {
Finite: u64,
#[default]
Infinite,
}
#[constructor]
fn constructor(ref self: ContractState, owner: Person) {
self.owner.write(owner);
}
#[abi(embed_v0)]
impl SimpleCounterImpl of super::ISimpleStorage<ContractState> {
fn get_owner(self: @ContractState) -> Person {
self.owner.read()
}
fn get_owner_name(self: @ContractState) -> felt252 {
self.owner.name.read()
}
fn get_expiration(self: @ContractState) -> Expiration {
self.expiration.read()
}
fn change_expiration(ref self: ContractState, expiration: Expiration) {
if get_caller_address() != self.owner.address.read() {
panic!("Only the owner can change the expiration");
}
self.expiration.write(expiration);
}
}
fn get_owner_storage_address(self: @ContractState) -> felt252 {
self.owner.__base_address__
}
fn get_owner_name_storage_address(self: @ContractState) -> felt252 {
self.owner.name.__storage_pointer_address__.into()
}
}
#[cfg(test)]
mod tests;
In this example, we've added the #[default] attribute to the Infinite
variant. This tells the Cairo compiler that if we try to read an uninitialized
enum from storage, the Infinite variant should be returned.
You might have noticed that we also derived Drop and Serde on our custom
types. Both of them are required for properly serializing arguments passed to
entrypoints and deserializing their outputs.
Structs Storage Layout
On Starknet, structs are stored in storage as a sequence of primitive types. The
elements of the struct are stored in the same order as they are defined in the
struct definition. The first element of the struct is stored at the base address
of the struct, which is computed as specified in the "Addresses of Storage
Variables" section and can be obtained with
var.__base_address__. Subsequent elements are stored at addresses contiguous
to the previous element. For example, the storage layout for the owner
variable of type Person will result in the following layout:
| Fields | Address |
|---|---|
| name | owner.__base_address__ |
| address | owner.__base_address__ +1 |
Note that tuples are similarly stored in contract's storage, with the first element of the tuple being stored at the base address, and subsequent elements stored contiguously.
Enums Storage Layout
When you store an enum variant, what you're essentially storing is the variant's
index and eventual associated values. This index starts at 0 for the first
variant of your enum and increments by 1 for each subsequent variant. If your
variant has an associated value, this value is stored starting from the address
immediately following the address of the index of the variant. For example,
suppose we have the Expiration enum with the Finite variant that carries an
associated limit date, and the Infinite variant without associated data. The
storage layout for the Finite variant would look like this:
| Element | Address |
|---|---|
| Variant index (0 for Finite) | expiration.__base_address__ |
| Associated limit date | expiration.__base_address__ + 1 |
while the storage layout for the Infinite variant would be as follows:
| Element | Address |
|---|---|
| Variant index (1 for Infinite) | expiration.__base_address__ |
Storage Nodes
A storage node is a special kind of struct that can contain storage-specific
types, such as Map, Vec, or other
storage nodes, as members. Unlike regular structs, storage nodes can only exist
within contract storage and cannot be instantiated or used outside of it. You
can think of storage nodes as intermediate nodes involved in address
calculations within the tree representing the contract's storage space. In the
next subsection, we will introduce how this concept is modeled in the core
library.
The main benefits of storage nodes is that they allow you to create more sophisticated storage layouts, including mappings or vectors inside custom types, and allow you to logically group related data, improving code readability and maintainability.
Storage nodes are structs defined with the #[starknet::storage_node]
attribute. In this new contract that implements a voting system, we implement a
ProposalNode storage node containing a Map<ContractAddress, bool> to keep
track of the voters of the proposal, along with other fields to store the
proposal's metadata.
#[starknet::contract]
mod VotingSystem {
use starknet::storage::{
Map, StoragePathEntry, StoragePointerReadAccess, StoragePointerWriteAccess,
};
use starknet::{ContractAddress, get_caller_address};
#[storage]
struct Storage {
proposals: Map<u32, ProposalNode>,
proposal_count: u32,
}
#[starknet::storage_node]
struct ProposalNode {
title: felt252,
description: felt252,
yes_votes: u32,
no_votes: u32,
voters: Map<ContractAddress, bool>,
}
#[external(v0)]
fn create_proposal(ref self: ContractState, title: felt252, description: felt252) -> u32 {
let mut proposal_count = self.proposal_count.read();
let new_proposal_id = proposal_count + 1;
let mut proposal = self.proposals.entry(new_proposal_id);
proposal.title.write(title);
proposal.description.write(description);
proposal.yes_votes.write(0);
proposal.no_votes.write(0);
self.proposal_count.write(new_proposal_id);
new_proposal_id
}
#[external(v0)]
fn vote(ref self: ContractState, proposal_id: u32, vote: bool) {
let mut proposal = self.proposals.entry(proposal_id);
let caller = get_caller_address();
let has_voted = proposal.voters.entry(caller).read();
if has_voted {
return;
}
proposal.voters.entry(caller).write(true);
}
}
When accessing a storage node, you can't read or write it directly. Instead,
you have to access its individual members. Here's an example from our
VotingSystem contract that demonstrates how we populate each field of the
ProposalNode storage node:
#[starknet::contract]
mod VotingSystem {
use starknet::storage::{
Map, StoragePathEntry, StoragePointerReadAccess, StoragePointerWriteAccess,
};
use starknet::{ContractAddress, get_caller_address};
#[storage]
struct Storage {
proposals: Map<u32, ProposalNode>,
proposal_count: u32,
}
#[starknet::storage_node]
struct ProposalNode {
title: felt252,
description: felt252,
yes_votes: u32,
no_votes: u32,
voters: Map<ContractAddress, bool>,
}
#[external(v0)]
fn create_proposal(ref self: ContractState, title: felt252, description: felt252) -> u32 {
let mut proposal_count = self.proposal_count.read();
let new_proposal_id = proposal_count + 1;
let mut proposal = self.proposals.entry(new_proposal_id);
proposal.title.write(title);
proposal.description.write(description);
proposal.yes_votes.write(0);
proposal.no_votes.write(0);
self.proposal_count.write(new_proposal_id);
new_proposal_id
}
#[external(v0)]
fn vote(ref self: ContractState, proposal_id: u32, vote: bool) {
let mut proposal = self.proposals.entry(proposal_id);
let caller = get_caller_address();
let has_voted = proposal.voters.entry(caller).read();
if has_voted {
return;
}
proposal.voters.entry(caller).write(true);
}
}
Because no voter has voted on this proposal yet, we don't need to populate the
voters map when creating the proposal. But we could very well access the
voters map to check if a given address has already voted on this proposal when
it tries to cast its vote:
#[starknet::contract]
mod VotingSystem {
use starknet::storage::{
Map, StoragePathEntry, StoragePointerReadAccess, StoragePointerWriteAccess,
};
use starknet::{ContractAddress, get_caller_address};
#[storage]
struct Storage {
proposals: Map<u32, ProposalNode>,
proposal_count: u32,
}
#[starknet::storage_node]
struct ProposalNode {
title: felt252,
description: felt252,
yes_votes: u32,
no_votes: u32,
voters: Map<ContractAddress, bool>,
}
#[external(v0)]
fn create_proposal(ref self: ContractState, title: felt252, description: felt252) -> u32 {
let mut proposal_count = self.proposal_count.read();
let new_proposal_id = proposal_count + 1;
let mut proposal = self.proposals.entry(new_proposal_id);
proposal.title.write(title);
proposal.description.write(description);
proposal.yes_votes.write(0);
proposal.no_votes.write(0);
self.proposal_count.write(new_proposal_id);
new_proposal_id
}
#[external(v0)]
fn vote(ref self: ContractState, proposal_id: u32, vote: bool) {
let mut proposal = self.proposals.entry(proposal_id);
let caller = get_caller_address();
let has_voted = proposal.voters.entry(caller).read();
if has_voted {
return;
}
proposal.voters.entry(caller).write(true);
}
}
In this example, we access the ProposalNode for a specific proposal ID. We
then check if the caller has already voted by reading from the voters map
within the storage node. If they haven't voted yet, we write to the voters map
to mark that they have now voted.
Addresses of Storage Variables
The address of a storage variable is computed as follows:
-
If the variable is a single value, the address is the
sn_keccakhash of the ASCII encoding of the variable's name.sn_keccakis Starknet's version of the Keccak256 hash function, whose output is truncated to 250 bits. -
If the variable is composed of multiple values (i.e., a tuple, a struct or an enum), we also use the
sn_keccakhash of the ASCII encoding of the variable's name to determine the base address in storage. Then, depending on the type, the storage layout will differ. See the "Storing Custom Types" section. -
If the variable is part of a storage node, its address is based on a chain of hashes that reflects the structure of the node. For a storage node member
mwithin a storage variablevariable_name, the path to that member is computed ash(sn_keccak(variable_name), sn_keccak(m)), wherehis the Pedersen hash. This process continues for nested storage nodes, building a chain of hashes that represents the path to a leaf node. Once a leaf node is reached, the storage calculation proceeds as it normally would for that type of variable. -
If the variable is a Map or a Vec, the address is computed relative to the storage base address, which is the
sn_keccakhash of the variable's name, and the keys of the mapping or indexes in the Vec. The exact computation is described in the "Storage Mappings" and "Storage Vecs" sections.
You can access the base address of a storage variable by accessing the
__base_address__ attribute on the variable, which returns a felt252 value.
use starknet::ContractAddress;
#[starknet::interface]
pub trait INameRegistry<TContractState> {
fn store_name(ref self: TContractState, name: felt252);
fn get_name(self: @TContractState, address: ContractAddress) -> felt252;
}
#[starknet::contract]
mod NameRegistry {
use starknet::storage::{
Map, StoragePathEntry, StoragePointerReadAccess, StoragePointerWriteAccess,
};
use starknet::{ContractAddress, get_caller_address};
#[storage]
struct Storage {
names: Map<ContractAddress, felt252>,
total_names: u128,
}
#[derive(Drop, Serde, starknet::Store)]
pub struct Person {
address: ContractAddress,
name: felt252,
}
#[constructor]
fn constructor(ref self: ContractState, owner: Person) {
self.names.entry(owner.address).write(owner.name);
self.total_names.write(1);
}
// Public functions inside an impl block
#[abi(embed_v0)]
impl NameRegistry of super::INameRegistry<ContractState> {
fn store_name(ref self: ContractState, name: felt252) {
let caller = get_caller_address();
self._store_name(caller, name);
}
fn get_name(self: @ContractState, address: ContractAddress) -> felt252 {
self.names.entry(address).read()
}
}
// Standalone public function
#[external(v0)]
fn get_contract_name(self: @ContractState) -> felt252 {
'Name Registry'
}
// Could be a group of functions about a same topic
#[generate_trait]
impl InternalFunctions of InternalFunctionsTrait {
fn _store_name(ref self: ContractState, user: ContractAddress, name: felt252) {
let total_names = self.total_names.read();
self.names.entry(user).write(name);
self.total_names.write(total_names + 1);
}
}
// Free function
fn get_total_names_storage_address(self: @ContractState) -> felt252 {
self.total_names.__base_address__
}
}
#[cfg(test)]
mod tests;
This address calculation mechanism is performed through a modelisation of the contract storage space using a concept of StoragePointers and StoragePaths that we'll now introduce.
Modeling of the Contract Storage in the Core Library
To understand how storage variables are stored in Cairo, it's important to note
that they are not stored contiguously but in different locations in the
contract's storage. To facilitate the retrieval of these addresses, the core
library provides a model of the contract storage through a system of
StoragePointers and StoragePaths.
Each storage variable can be converted to a StoragePointer. This pointer
contains two main fields:
- The base address of the storage variable in the contract's storage.
- The offset, relative to the base address, of the specific storage slot being pointed to.
An example is worth a thousand words. Let's consider the Person struct defined
in the previous section:
#[starknet::interface]
pub trait ISimpleStorage<TContractState> {
fn get_owner(self: @TContractState) -> SimpleStorage::Person;
fn get_owner_name(self: @TContractState) -> felt252;
fn get_expiration(self: @TContractState) -> SimpleStorage::Expiration;
fn change_expiration(ref self: TContractState, expiration: SimpleStorage::Expiration);
}
#[starknet::contract]
mod SimpleStorage {
use starknet::storage::{StoragePointerReadAccess, StoragePointerWriteAccess};
use starknet::{ContractAddress, get_caller_address};
#[storage]
struct Storage {
owner: Person,
expiration: Expiration,
}
#[derive(Drop, Serde, starknet::Store)]
pub struct Person {
address: ContractAddress,
name: felt252,
}
#[derive(Copy, Drop, Serde, starknet::Store)]
pub enum Expiration {
Finite: u64,
#[default]
Infinite,
}
#[constructor]
fn constructor(ref self: ContractState, owner: Person) {
self.owner.write(owner);
}
#[abi(embed_v0)]
impl SimpleCounterImpl of super::ISimpleStorage<ContractState> {
fn get_owner(self: @ContractState) -> Person {
self.owner.read()
}
fn get_owner_name(self: @ContractState) -> felt252 {
self.owner.name.read()
}
fn get_expiration(self: @ContractState) -> Expiration {
self.expiration.read()
}
fn change_expiration(ref self: ContractState, expiration: Expiration) {
if get_caller_address() != self.owner.address.read() {
panic!("Only the owner can change the expiration");
}
self.expiration.write(expiration);
}
}
fn get_owner_storage_address(self: @ContractState) -> felt252 {
self.owner.__base_address__
}
fn get_owner_name_storage_address(self: @ContractState) -> felt252 {
self.owner.name.__storage_pointer_address__.into()
}
}
#[cfg(test)]
mod tests;
When we write let x = self.owner;, we access a variable of type StorageBase
that represents the base location of the owner variable in the contract's
storage. From this base address, we can either get pointers to the struct's
fields (like name or address) or a pointer to the struct itself. On these
pointers, we can call read and write, defined in the Store trait, to read
and write the values pointed to.
Of course, all of this is transparent to the developer. We can read and write to
the struct's fields as if we were accessing regular variables, but the compiler
translates these accesses into the appropriate StoragePointer manipulations
under the hood.
For storage mappings, the process is similar, except that we introduce an
intermediate type, StoragePath. A StoragePath is a chain of storage nodes
and struct fields that form a path to a specific storage slot. For example, to
access a value contained in a Map<ContractAddress, u128>, the process would be
the following:
- Start at
StorageBaseof theMap, and convert it to aStoragePath. - Walk the
StoragePathto reach the desired value using theentrymethod, which, in the case of aMap, hashes the current path with the next key to generate the nextStoragePath. - Repeat step 2 until the
StoragePathpoints to the desired value, converting the final value to aStoragePointer - Read or write the value at that pointer.
Note that we need to convert the ContractAddress to a StoragePointer before
being able to read or write to it.

Summary
In this chapter, we covered the following key points:
- Storage Variables: These are used to store persistent data on the
blockchain. They are defined in a special
Storagestruct annotated with the#[storage]attribute. - Accessing Storage Variables: You can read and write storage variables
using automatically generated
readandwritefunctions. For structs, you can access individual members directly. - Custom Types with the
StoreTrait: To store custom types like structs and enums, they must implement theStoretrait. This can be achieved using the#[derive(starknet::Store)]attribute or writing your own implementation. - Addresses of Storage Variables: The address of a storage variable is
computed using the
sn_keccakhash of its name, and additional steps for special types. For complex types, the storage layout is determined by the type's structure. - Structs and Enums Storage Layout: Structs are stored as a sequence of primitive types, while enums store the variant index and potential associated values.
- Storage Nodes: Special structs that can contain storage-specific types
like
MaporVec. They allow for more sophisticated storage layouts and can only exist within contract storage.
Next, we'll focus on the Map and Vec types in depth.