TL;DR
Cairo 2.7.0 will be released soon! At the moment, 2.7.0-rc.3 is available and can be tested by the community. This version involves a Sierra upgrade to v1.6.0, which means contracts written with v2.7.0 are only deployable on Starknet ≥ 0.13.2. The Testnet upgrade is planned for Aug 5, and the Mainnet upgrade is planned for Aug 26 (you can find more details here). As usual, you can continue using older compiler versions for your contracts and deploy them on Starknet.
v2.7.0 is a HUGE version with an abundance of new features & updates:
- New edition,
2024_07
- The
Deref
andDerefMut
traits are added to the corelib, which allows the compiler to, given a deref implementation, treat the source type as the target type - Contract storage is now much more flexible:
- Allowing read/write for individual members for every type that derives
Store
- Introducing the new
Map
type (you no longer have to use legacy maps!) - Introducing the
Vec
type - Adding a new “storage node” concept that allows arbitrary nesting (e.g. structs with maps, or properties that are themselves storage nodes)
- Allowing read/write for individual members for every type that derives
- Phantom types
- Associated items are introduced to Cairo, allowing you to define types, consts and impls associated with a given trait.
- for loops - we now have
for elem in array
- sha256 support in the corelib
- default implementations
- fixed-size array type
- Arithmetic circuits support - construct and run arithmetic circuits defined over arbitrary modulus up to 384 bits. This is particularly useful to allow efficient verification over Starknet for different proof systems, e.g. groth16 (the Garaga project in particular is a good example of a project that relies on this feature)
The above only includes the most significant new features, for a comprehensive list of changes see the release notes. We now proceed to dive into each of the changes mentioned above.
Breaking changes
While we’re trying to avoid breaking any existing code in minor versions, we may allow changing internal traits or generated code that could affect existing user code. While not affecting the majority of contracts, we cover the list of potential breaking changes below:
- The compiler generated
<storage_var_name>ContractMemberStateTrait
traits, wherestorage_var_name
is a name of a property of theStorage
struct, no longer exist. If your code depended directly on one of these traits, then it will break. - The return type of
contract_state_for_testing
andcomponent_state_for_testing
will not have access to the storage members. For example:
let state = MyContract::contract_state_for_testing();
let value = state.storage_var.read(); // won't compile
To fix this, add mut
or @
as follows:
let state = @MyContract::contract_state_for_testing();
state.storage_var.read(...);
let mut state = MyContract::contract_state_for_testing();
state.storage_var.write(...);
The 2024_07 edition
This new edition no longer auto adds pub
modifiers to the Storage
sturct and its members, this is now the responsibility of the developer. This is of particular importance for components, whose Storage
struct is referred to by external contracts.
We’re also narrowing the prelude, removing several traits which are not common to every contract. For a full diff, you can compare the old and new preule (the old prelude is the same for 2023_10 and 2023_11): v2023_10.cairo, v2024_07.cairo. Note that in particular, all storage access traits were added to 2023_10 and 2023_11, but need to be imported with 2024_07.
Deref
This version adds the Deref
and DerefMut
traits to the compiler. Similarly to Rust, these are special traits known to the compiler:
pub trait Deref<T> {
type Target;
fn deref(self: T) -> Self::Target;
}
pub trait DerefMut<T> {
type Target;
fn deref_mut(ref self: T) -> Self::Target;
}
When type T
can be dereferenced to type K
, then we can access K’s members while holding an instance of T
. Note that any time has at most one deref
implementation. Additionally, for now only member access via deref is supported, i.e. impls with functions whose self
argument is of type K
will not be applicable when holding an instance of T
. To illustrate how Deref
works, consider the following example:
#[derive(Drop)]
struct Type1 {
a: u8
}
#[derive(Drop)]
struct Type2 {
b: u8
}
impl DerefType1ToType2 of Deref<Type1> {
type Target = Type2;
fn deref(self: Type1) -> Type2 {
Type2 { b: self.a }
}
}
Thanks to the above Deref
implementation, we can write the following:
let t1 = Type1 { a: 3 };
let b = t1.b;
Note that for the following to work, we need an implementation of DerefMut
rather than Deref
:
fn foo(ref t1: Type1) {
let b = t1.b;
}
impl DerefType1ToType2 of DerefMut<Type1> {
type Target = Type2;
fn deref_mut(ref self: Type1) -> Type2 {
Type2 { b: self.a }
}
}
Contract Storage
Up until now the storage mechanism solely relied on the Store
trait. If a type T
had a Store<T>
implementation, then Storage
could contain members of type T
(the only exception being components, where the member type was the component’s Storage
type). For example:
#[derive(starknet::Store)]
struct MyStruct {
a: u128,
b: u128
}
#[storage]
struct Storage {
member: MyStruct
}
When reading or writing to member
, it was only possible to read or write the struct in its entirety, i.e. via self.member.read()
. In v2.7.0, this mechanism is generalized.
To allow this generalization, much of the code generation around contract storage was refactored. In particular, the ContractMemberState
traits that were generated per each member of the Storage
struct no longer exist. That is, if your code uses these generated traits explicitly, it will break. The role of these traits is now fulfilled by the ContractStorageBase
and ContractStorageBaseMut
generated structs. To understand those in more detail, read the “under the hood” sections below.
Individual member access
Consider the above contract; in Cairo v2.7.0 onwards, the following syntax is supported:
fn storage_access(ref self: ContractState) {
let member: MyStruct = self.member.read() // old
let a = self.member.a.read(); // new
self.member.b.write(5); // new
}
Note that this is only supported for types that explicitly derive Store
, and won’t work for custom Store
implementation (e.g. ones that are obtained by packing).
Individual member access - under the hood
In the new version, derive(Store)
will generate, in addition to a Store
impl, a corresponding SubPointers type and an implementation of the SubPointers trait for this type. For our above example, the following code will be generated:
#[derive(Drop, Copy)]
struct MyStructSubPointers {
a: starknet::storage::StoragePointer<u128>,
b: starknet::storage::StoragePointer<u128>,
}
struct MutableMyStructSubPointers {
a: starknet::storage::StoragePointer<Mutable<u128>>,
b: starknet::storage::StoragePointer<Mutable<u128>>,
}
impl MyStructSubPointersImpl of starknet::storage::SubPointers<MyStruct> {
type SubPointersType = MyStructSubPointers;
fn sub_pointers(self: starknet::storage::StoragePointer<MyStruct>) -> MyStructSubPointers {
let base_address = self.address;
let mut current_offset = self.offset;
let a_value = starknet::storage::StoragePointer {
address: base_address,
offset: current_offset,
};
current_offset = current_offset + starknet::Store::<u128>::size();
let b_value = starknet::storage::StoragePointer {
address: base_address,
offset: current_offset,
};
MyStructSubPointers {
a: a_value,
b: b_value,
}
}
}
impl MutableMyStructSubPointersImpl of starknet::storage::MutableSubPointers<MyStruct> {
type SubPointersType = MutableMyStructSubPointers;
fn mutable_sub_pointers(self: starknet::storage::StoragePointer<Mutable<MyStruct>>) -> MutableMyStructSubPointers {
let base_address = self.address;
let mut current_offset = self.offset;
let a_value = starknet::storage::StoragePointer {
address: base_address,
offset: current_offset,
};
current_offset = current_offset + starknet::Store::<u128>::size();
let b_value = starknet::storage::StoragePointer {
address: base_address,
offset: current_offset,
};
MutableMyStructSubPointers {
a: a_value,
b: b_value,
}
}
}
StoragePointer
is a new type defined in storage.cairo, it points to a concrete storage slot via a base address and an offset. The above generated code explains why only structs that explicitly derive Store
allow individual member access. The generated code assumes that each member is stored via its type’s Store
implementation, which is not necessarily the case for custom implementations, e.g. ones based on packing.
To understand how to get from self:ContractState
to self.member.a.read()
, we need to look into the types in question. The ContractState
type is an empty struct generated by the compiler, that can be derefed to the ContractStorageBase
or ContractStorageBaseMut
types that are generated based on the Storage
struct defined in the contract module:
#[derive(Drop, Copy)]
struct ContractStorageBase {
member: starknet::storage::StorageBase<MyStruct>,
}
#[derive(Drop, Copy)]
struct ContractStorageBaseMut {
member: starknet::storage::StorageBase<starknet::storage::Mutable<MyStruct>>,
}
pub struct ContractState {}
impl StorageBaseImpl of starknet::storage::StorageBaseTrait<ContractState> {
type BaseType = ContractStorageBase;
type BaseMutType = ContractStorageBaseMut;
fn storage_base(self: @ContractState) -> ContractStorageBase {
ContractStorageBase {
member: starknet::storage::StorageBase{ address: selector!("balance") },
}
}
fn storage_base_mut(ref self: ContractState) -> ContractStorageBaseMut {
ContractStorageBaseMut {
member: starknet::storage::StorageBase{ address: selector!("balance") },
}
}
}
impl ContractStateDeref of core::ops::SnapshotDeref<ContractState> {
type Target = ContractStorageBase;
fn snapshot_deref(self: @ContractState) -> ContractStorageBase {
self.storage_base()
}
}
impl ContractStateDerefMut of core::ops::DerefMut<ContractState> {
type Target = ContractStorageBaseMut;
fn deref_mut(ref self: ContractState) -> ContractStorageBaseMut {
self.storage_base_mut()
}
}
The above generated code shows us how self.member
works, via the DerefMut
implementation between ref ContractState
and ContractStorageBaseMut
that has member
of type StorageBase<Mutable<MyStruct>>
. To see how we can get from StorageBase<Mutable<MyStruct>>
to MyStruct
’s members, we need to follow the chain of derefs defined in storage.cairo:
// StorageBase<Mutable<MyStruct>> --> StoragePath<Mutable<MyStruct>>
impl StorageBaseDeref<T> of core::ops::Deref<StorageBase<T>> {
type Target = StoragePath<T>;
fn deref(self: StorageBase<T>) -> Self::Target {
self.as_path()
}
}
StorageBase
represents the initial address of some type in storage. StoragePath
is a struct that maintains a hash state that can be modified until we reach the storage location we need, and was devised mostly for Map
accesses (or more complex storage accesses, such as storage nodes that we’ll see in subsequent sections).
We can now convert the StoragePath
to a storage pointer by finalizing the hash state:
// StoragePath<Mutable<MyStruct>> --> StoragePointer0Offset<Mutable<MyStruct>>
impl StoragePathDeref<
T, impl PointerImpl: StorageAsPointer<StoragePath<T>>
> of core::ops::Deref<StoragePath<T>> {
type Target = StoragePointer0Offset<PointerImpl::Value>;
fn deref(self: StoragePath<T>) -> StoragePointer0Offset<PointerImpl::Value> {
self.as_ptr()
}
}
// T is Mutable<MyStruct>, this impl gives us `as_ptr` that is used above
impl MutableStorableStoragePathAsPointer<
T, +MutableTrait<T>, +starknet::Store<MutableTrait::<T>::InnerType>
> of StorageAsPointer<StoragePath<T>> {
type Value = T;
fn as_ptr(self: @StoragePath<T>) -> StoragePointer0Offset<T> {
StoragePointer0Offset { address: (*self).finalize() }
}
}
// StoragePointer0Offset<Mutable<MyStruct>> --> StoragePointer<Mutable<MyStruct>>
impl StoragePointer0OffsetDeref<T> of core::ops::Deref<StoragePointer0Offset<T>> {
type Target = StoragePointer<T>;
fn deref(self: StoragePointer0Offset<T>) -> StoragePointer<T> {
StoragePointer::<T> { address: self.address, offset: 0 }
}
}
pub struct Mutable<T> {}
trait MutableTrait<T> {
type InnerType;
}
impl MutableImpl<T> of MutableTrait<Mutable<T>> {
type InnerType = T;
}
The above derefs brought us to StoragePointer<Mutable<MyStruct>>
. This is where the new sub pointers types kick in, via the following two derefs in storage.cairo:
// StoragePointer<MyStruct> --> MyStructSubPointers
impl SubPointersDeref<T, +SubPointers<T>> of core::ops::Deref<StoragePointer<T>> {
type Target = SubPointers::<T>::SubPointersType;
fn deref(self: StoragePointer<T>) -> Self::Target {
self.sub_pointers()
}
}
// StoragePointer<Mutable<MyStruct> --> MutableMyStructSubPointers
impl MutableSubPointersDeref<
T, +MutableSubPointers<T>
> of core::ops::Deref<StoragePointer<Mutable<T>>> {
type Target = MutableSubPointers::<T>::SubPointersType;
fn deref(self: StoragePointer<Mutable<T>>) -> Self::Target {
self.mutable_sub_pointers()
}
}
Now that we hold the MutableMyStructSubPointers
type, to see why self.member.a
works, we only need a reminder of the sub pointers type definition:
struct MutableMyStructSubPointers {
a: starknet::storage::StoragePointer<Mutable<u128>>,
b: starknet::storage::StoragePointer<Mutable<u128>>,
}
Finally, self.a.read()
works since StoragePointer<T>
exposes read, and self.a.write()
works since StoragePointer<Mutable<T>>
exposes write:
// Store-based read for StoragePointer<T>
pub impl StorableStoragePointerReadAccess<
T, +starknet::Store<T>
> of StoragePointerReadAccess<StoragePointer<T>> {
type Value = T;
fn read(self: @StoragePointer<T>) -> T {
starknet::SyscallResultTrait::unwrap_syscall(
starknet::Store::<T>::read_at_offset(0, *self.address, *self.offset)
)
}
}
// When T is `Mutable<K>`, we need a separate "read" implementation in order to reach the internal type
impl MutableStorableStoragePointerReadAccess<
T, +MutableTrait<T>, +starknet::Store<MutableTrait::<T>::InnerType>
> of StoragePointerReadAccess<StoragePointer<T>> {
type Value = MutableTrait::<T>::InnerType;
fn read(self: @StoragePointer<T>) -> MutableTrait::<T>::InnerType {
starknet::SyscallResultTrait::unwrap_syscall(
starknet::Store::<
MutableTrait::<T>::InnerType
>::read_at_offset(0, *self.address, *self.offset)
)
}
}
// Write implementation for StoragePointer<T> where T is Mutable<K>
impl MutableStorableStoragePointerWriteAccess<
T, +MutableTrait<T>, +starknet::Store<MutableTrait::<T>::InnerType>
> of StoragePointerWriteAccess<StoragePointer<T>> {
type Value = MutableTrait::<T>::InnerType;
fn write(self: StoragePointer<T>, value: MutableTrait::<T>::InnerType) {
starknet::SyscallResultTrait::unwrap_syscall(
starknet::Store::<
MutableTrait::<T>::InnerType
>::write_at_offset(0, self.address, self.offset, value)
)
}
}
To summarize, we followed the path:
ref ContractState
→ (deref) → ContractStorageBaseMut
→ (self.member
) → StorageBase<Mutable<MyStruct>>
→ (deref) → StoragePath<Mutable<MyStruct>>
→ (deref) → StoragePointer0Offset<Mutable<MyStruct>>
→ (deref) → StoragePointer<Mutable<MyStruct>>
→ (deref) → MutableMyStructSubPointers
.
Maps
Say goodbye to LegacyMap
. Map
is a new and more flexible type for maintaining mappings in a contract storage. With Map<K,V>
we can:
- Have nested Maps (rather than having a tuple type key with
LegacyMap
) - Have
Map
as a member of a struct (we will dive into this in the followingstorage_node
section)
Note that the storage layout of Map<K, V>
is identical to that LegacyMap<K,V>
, hence you can safely migrate to the new type.
The Map
type can not be instantiated via user code.
To illustrate the use of the new type, consider the following simple contract:
#[starknet::contract]
mod simple_contract {
use starknet::{ContractAddress, contract_address_const};
use starknet::storage::{Map, StoragePathEntry};
/// the latest edition 2024_07 also requires the following imports:
use starknet::storage::{StorageMapReadAccess, StorageMapWriteAccess}
#[storage]
struct Storage {
basic: u128,
balances: Map<ContractAddress, u256>,
nested_balances: Map<ContractAddress, Map<ContractAddress, u256>>,
}
#[abi(per_item)]
#[generate_trait]
pub impl SimpleContract of SimpleContractTrait {
#[external(v0)]
fn test_new_storage(ref self: ContractState) {
let address = contract_address_const::<1>();
self.balances.entry(address).read();
self.nested_balances.entry(address).entry(address).read();
self.nested_balances.entry(address).entry(address).write(5);
}
}
}
Maps - under the hood
The basic types that the new maps require are Map
and EntryInfo
. The purpose of these types is for us to be able to refer to the key type and value type from a Map
instance, as illustrated by the following code in the corelib:
#[phantom]
pub struct Map<K, V> {}
/// A trait for making a map like type support implement the `StoragePathEntry` trait.
trait EntryInfo<T> {
type Key;
type Value;
}
impl EntryInfoImpl<K, V> of EntryInfo<Map<K, V>> {
type Key = K;
type Value = V;
}
Accessing entires of a collection C
is done via implementing the following trait:
pub trait StoragePathEntry<C> {
type Key;
type Value;
fn entry(self: C, key: Self::Key) -> StoragePath<Self::Value>;
}
Now we can proceed to show how the code from our previous example works:
self.balances.entry(address).read()
We already know, from diving into the implementation of individual members access, that ContractState
can be dereferenced to ContractStorageBase
, where balances
is of type StorageBase<Map<ContractAddress, u256>
, which can then be dereferenced StoragePath<Map<ContractAddress, u256>>
(to get write access, we would need to start from ref ContractState
, which can be derefed to ContractStorageBaseMut
, which in turn has a balances
member of type StoragePath<Mutable<Map<ContractAddress, u256>>>
).
From StoragePath<Map<ContractAddress, u256>>
or StoragePath<Mutable<Map<ContractAddress, u256>>>
we get the entry
functionality via one of the following implementations:
impl EntryInfoStoragePathEntry<
T,
+EntryInfo<T>,
+core::hash::Hash<EntryInfo::<T>::Key,
StoragePathHashState>
> of StoragePathEntry<StoragePath<T>> {
type Key = EntryInfo::<T>::Key;
type Value = EntryInfo::<T>::Value;
fn entry(self: StoragePath<T>, key: EntryInfo::<T>::Key) -> StoragePath<EntryInfo::<T>::Value> {
StoragePath::<
EntryInfo::<T>::Value
> {
hash_state: core::hash::Hash::<
EntryInfo::<T>::Key, StoragePathHashState
>::update_state(self.hash_state, key)
}
}
}
impl MutableEntryStoragePathEntry<
T,
+MutableTrait<T>,
impl EntryImpl: EntryInfo<MutableTrait::<T>::InnerType>,
+core::hash::Hash<EntryImpl::Key,
StoragePathHashState>
> of StoragePathEntry<StoragePath<T>> {
type Key = EntryImpl::Key;
type Value = Mutable<EntryImpl::Value>;
fn entry(self: StoragePath<T>, key: EntryImpl::Key) -> StoragePath<Mutable<EntryImpl::Value>> {
StoragePath::<
Mutable<EntryImpl::Value>
> {
hash_state: core::hash::Hash::<
EntryImpl::Key, StoragePathHashState
>::update_state(self.hash_state, key)
}
}
}
Above, we can see that entry
modifies the StoragePath
with a single hash application (specifically, Pedersen hash, as StoragePathHashState
is just an alias to PedersenHashState
). After calling entry, we end up with StoragePath<EntryInfo::<T>::Value>
or StoragePath<Mutable<EntryImpl::Value>>
. Note that Value
can either implement Store
, or itself be another Map
, over which we can again call entry
. If Value
implements Store
, then read access will be give by one of the following impls:
impl StorableEntryReadAccess<
T,
+EntryInfo<T>,
+core::hash::Hash<EntryInfo::<T>::Key,StoragePathHashState>,
+starknet::Store<EntryInfo::<T>::Value>,
> of StorageMapReadAccessTrait<StoragePath<T>> {
type Key = EntryInfo::<T>::Key;
type Value = EntryInfo::<T>::Value;
fn read(self: StoragePath<T>, key: EntryInfo::<T>::Key) -> EntryInfo::<T>::Value {
self.entry(key).as_ptr().read()
}
}
impl MutableStorableEntryReadAccess<
T,
+MutableTrait<T>,
+EntryInfo<MutableTrait::<T>::InnerType>,
+core::hash::Hash<EntryInfo::<MutableTrait::<T>::InnerType>::Key, StoragePathHashState>,
+starknet::Store<EntryInfo::<MutableTrait::<T>::InnerType>::Value>,
> of StorageMapReadAccessTrait<StoragePath<T>> {
type Key = EntryInfo::<MutableTrait::<T>::InnerType>::Key;
type Value = EntryInfo::<MutableTrait::<T>::InnerType>::Value;
#[inline(always)]
fn read(
self: StoragePath<T>, key: EntryInfo::<MutableTrait::<T>::InnerType>::Key
) -> EntryInfo::<MutableTrait::<T>::InnerType>::Value {
self.entry(key).as_ptr().read()
}
}
A similar impl will give us write capabilities with the type StoragePath<Mutable<EntryImpl::Value>>
.
Storage vecs
So far, to have sequential access to a collection in storage, one needed to use maps or a custom implementation, e.g., as done by OpenZeppelin. This version introduces the Vec
type, which allows keeping vectors in a contract’s storage.
Below is an example of how one can use storage vectors:
use starknet::storage::{Vec, VecTrait, MutableVecTrait};
#[storage]
struct Storage {
var1: Vec<u256>,
var2: Vec<Map<u8, Vec<u8>>>
}
fn foo(ref self: ContractState) {
self.var1.append().write(1);
self.var1.append().write(2);
assert!(self.var1.len() == 2);
assert!(self.var1[0].read() == 1 && self.var1[1].read() == 2);
self.var1[0].write(4);
self.var2.append().entry(5).append().write(1);
assert!(self.var2[0].entry(5)[0].read() == 1);
self.var1[3].read(); // panic (out of bounds)
}
Storage vecs - under the hood
The VecTrait
and MutableVecTrait
traits give us the ability to access vec indices and append new elements. These impls operate on StoragePath<Vec<T>>
and StoragePath<Mutable<Vec<T>>>
, which are the member types of the generated ContractStorageBase
and ContractStorageBaseMut
types, as we’ve seen in “individual storage access - under the hood”.
pub struct Vec<T> {}
pub trait VecTrait<T> {
type ElementType;
fn at(self: T, index: u64) -> StoragePath<Self::ElementType>;
fn len(self: T) -> u64;
}
pub trait MutableVecTrait<T> {
type ElementType;
fn at(self: T, index: u64) -> StoragePath<Mutable<Self::ElementType>>;
fn len(self: T) -> u64;
fn append(self: T) -> StoragePath<Mutable<Self::ElementType>>;
}
impl VecImpl<T> of VecTrait<StoragePath<Vec<T>>> {
type ElementType = T;
fn get(self: StoragePath<Vec<T>>, index: u64) -> Option<StoragePath<T>> {
let vec_len = self.len();
if index < vec_len {
Option::Some(self.update(index))
} else {
Option::None
}
}
fn at(self: StoragePath<Vec<T>>, index: u64) -> StoragePath<T> {
assert!(index < self.len(), "Index out of bounds");
self.update(index)
}
fn len(self: StoragePath<Vec<T>>) -> u64 {
self.as_ptr().read()
}
}
impl MutableVecImpl<T> of MutableVecTrait<StoragePath<Mutable<Vec<T>>>> {
type ElementType = T;
fn get(self: StoragePath<Mutable<Vec<T>>>, index: u64) -> Option<StoragePath<Mutable<T>>> {
let vec_len = self.len();
if index < vec_len {
Option::Some(self.update(index))
} else {
Option::None
}
}
fn at(self: StoragePath<Mutable<Vec<T>>>, index: u64) -> StoragePath<Mutable<T>> {
assert!(index < self.len(), "Index out of bounds");
self.update(index)
}
fn len(self: StoragePath<Mutable<Vec<T>>>) -> u64 {
self.as_ptr().read()
}
fn append(self: StoragePath<Mutable<Vec<T>>>) -> StoragePath<Mutable<T>> {
let vec_len = self.len();
self.as_ptr().write(vec_len + 1);
self.update(vec_len)
}
}
Note that the storage layout of Vec
is determined by the get
implementation, which computes the address of the n’th element as h(base_address, n)
, where h
is the Pedersen hash function. The length of the array is held at base_address
, where the base address is determined by the location of the array with respect to the Storage
struct. For example, in the case of var1
above, the base address is sn_keccak("var1")
, while the base address of self.var2[0].entry(5).read()
is pedersen(sn_keccak("var2"), 5)
.
Storage nodes
Storage nodes are a new concept introduced in Cairo v2.7.0. The motivation behind them was to have Map
, or other “special” types that do not implement Store
but can appear inside Storage
, as potential members of structs.
You can think of storage nodes as nodes in a tree that lie within the contract storage space. Content is stored in the leaves, and intermediate nodes allow you to point to other locations in storage. Consider the following example:
struct Storage {
member: MyNode
}
#[starknet::storage_node]
struct MyNode {
a: u8,
b: u256,
c: Map<u256, u256>
}
fn foo(self: @ContractState) {
self.member.c.entry(5).read();
}
Thanks to the #[starknet::storage_node]
attribute over MyNode
, we can have storage variables of type MyNode
, although it doesn’t implement Store
.
The storage layout of MyNode
will be based on the names of its members: a
, b
, and c
. For example, the actual storage key read by self.member.c.entry(5).read()
is:
h(sn_keccak("member"), h(sn_keccak("c"), 5))
,
where h
is the Pedersen hash function.
Maps in structs are not the only thing that you can do with storage nodes. To illustrate their strength, consider the following example:
struct Storage {
root: StorageTreeNode
}
#[starknet::storage_node]
struct StorageTreeNode {
value: u32,
left: StorageTreeNode,
right: StorageTreeNode
}
Without the #[starknet::storage_node]
attribute, the above code would not have compiled as StorageTreeNode
has infinite size. The #[starknet::storage_node]
attribute tells the compiler that StorageTreeNode
will never be instantiated directly and will only serve to point to storage locations.
Note that to make the above example interesting, you would need another property on StorageTreeNode
which tells you whether or not you’re at a leaf (or, e.g., change value
to Option<u32>
, where None
indicates that you hit a leaf). It’s important to emphasize that a storage node does not carry value by itself, it is essentially a path to some storage location from which I can read members. Hence, I cannot ask if self.root.left
is None
, it is a path to some location in storage, I can always point to it. Any actual information should be kept in storeable members of the storage node.
Storage nodes - under the hood
Throughout this section, we’ll use the following storage node as our example:
#[storage]
struct Storage {
member: MyNode
}
#[starknet::storage_node]
struct MyNode {
a: u8,
b: Map<u256, u256>,
c: MyNode
}
fn foo(self: @ContractState) {
self.member.a.read();
self.member.b.entry(5).read();
self.c.c.a.read();
}
When trying to work out self.member.a.read()
, without the new #[storage_node]
attribute, trying to follow the path from “individual member access - under the hood”, we will break when trying to deref from
StoragePath<MyNode>
to StoragePointer0Offset<MyNode>, since the deref implementation needs an impl of the
StorageAsPointer trait, and the following generic implementation in the corelib won’t help since we don’t have Store
:
pub trait StorageAsPointer<TMemberState> {
type Value;
fn as_ptr(self: @TMemberState) -> StoragePointer0Offset<Self::Value>;
}
impl StorableStoragePathAsPointer<T, +starknet::Store<T>> of StorageAsPointer<StoragePath<T>> {
type Value = T;
fn as_ptr(self: @StoragePath<T>) -> StoragePointer0Offset<T> {
StoragePointer0Offset { address: (*self).finalize() }
}
}
Thus, we need a new way to get read
from StoragePath<MyNode>
without relying on Store
(we will ignore write
in this section since it is a repetition of the same Mutable<T>
trick we’ve seen in the previous sections).
When annotating MyNode
with the #[starknet::storage_node]
attribute, the following code is generated:
#[derive(Drop, Copy)]
struct MyNodeStorageNode {
a: starknet::storage::PendingStoragePath<u8>,
b: starknet::storage::PendingStoragePath<Map<u256, u256>>,
c: starknet::storage::PendingStoragePath<MyNode>,
}
impl MyNodeStorageNodeImpl of StorageNode<MyNode> {
type NodeType = BalancePairStorageNode;
fn storage_node(self: StoragePath<MyNode>) -> MyNodeStorageNode {
let a_value = PendingStoragePathTrait::new(
@self,
selector!("a")
);
let b_value = PendingStoragePathTrait::new(
@self,
selector!("b")
);
let c_value = PendingStoragePathTrait::new(
@self,
selector!("c")
);
MyNodeStorageNode {
a: a_value,
b: b_value,
c: c_value
}
}
}
The above is similar to the generated sub pointers type, except now we need to encode MyNode
member names in the storage path somehow. To this end, the PendingStoragePath
type was introduced:
struct PendingStoragePath<T> {
hash_state: StoragePathHashState, // PedersenHashState
pending_key: felt252
}
Now, the following deref implementation in the corelib completes the picture:
impl StorageNodeDeref<T, +StorageNode<T>> of core::ops::Deref<StoragePath<T>> {
type Target = StorageNode::<T>::NodeType;
fn deref(self: StoragePath<T>) -> Self::Target {
self.storage_node()
}
}
This is what gets us from StoragePath<MyNode>
to MyNodeStorageNode
, a struct whose members are a
, b
, and c
(same as MyNode
), but with the member types being wrapped by PendingStoragePath
.
The read
functionality on PendingStoragePath<u8>
comes from the following impls in the corelib:
// PendingStoragePath --> StoragePath
impl PendingStoragePathAsPath<T> of StorageAsPath<PendingStoragePath<T>> {
type Value = T;
fn as_path(self: @PendingStoragePath<T>) -> StoragePath<T> {
StoragePath::<
T
> { hash_state: core::hash::HashStateTrait::update(*self.hash_state, *self.pending_key) }
}
}
// T is PendingStoragePath<u8>
impl StorablePathableStorageAsPointer<
T,
impl PathImpl: StorageAsPath<T>,
// PathImpl::Value is u8, the StorageAsPointer<u8> impl is given by `StorableStoragePathAsPointer`
impl PtrImpl: StorageAsPointer<StoragePath<PathImpl::Value>>,
> of StorageAsPointer<T> {
type Value = PtrImpl::Value;
fn as_ptr(self: @T) -> StoragePointer0Offset<PtrImpl::Value> {
let path = self.as_path();
path.as_ptr()
}
}
// This impl grants us `self.member.a.read()`
impl StorablePointerReadAccessImpl<
T,
impl PointerImpl: StorageAsPointer<T>,
impl AccessImpl: StoragePointerReadAccess<StoragePointer0Offset<PointerImpl::Value>>,
+Drop<T>,
+Drop<AccessImpl::Value>,
> of StoragePointerReadAccess<T> {
type Value = AccessImpl::Value;
fn read(self: @T) -> Self::Value {
self.as_ptr().read()
}
}
The above shows us how can we get access to “storable” members of the storage node. But what about self.member.b.entry(5).read()
or self.member.c.c.a.read()
.
To get access to the map member via
self.member.b.entry(5).read()
we need the following two impls:
// T is PendingStoragePath<Map<u256, u256>>`
impl PathableStorageEntryImpl<
T,
impl PathImpl: StorageAsPath<T>,
impl EntryImpl: StoragePathEntry<StoragePath<PathImpl::Value>>,
+Drop<T>,
+Drop<EntryImpl::Key>,
> of StoragePathEntry<T> {
type Key = EntryImpl::Key;
type Value = EntryImpl::Value;
fn entry(self: T, key: Self::Key) -> StoragePath<Self::Value> {
let path = PathImpl::as_path(@self);
EntryImpl::entry(path, key)
}
}
// T is `StoragePath<Map<u256, u256>>`
impl StorageAsPathReadForward<
T,
impl PathImpl: StorageAsPath<T>,
impl AccessImpl: StorageMapReadAccessTrait<StoragePath<PathImpl::Value>>,
+Drop<T>,
+Drop<AccessImpl::Key>,
> of StorageMapReadAccessTrait<T> {
type Key = AccessImpl::Key;
type Value = AccessImpl::Value;
#[inline(always)]
fn read(self: T, key: AccessImpl::Key) -> AccessImpl::Value {
self.as_path().read(key)
}
}
To keep reading the next storage node via e.g.
self.member.c.c.a.read()
we need to go back from PendingStoragePath<MyNode>
back to StoragePath<MyNode>
, from which we can again call the storage_node()
function and obtain the PendingStoragePath<MyNode>
type. This is achieve by the following deref impl:
impl PendingStoragePathDeref<T> of core::ops::Deref<PendingStoragePath<T>> {
type Target = StoragePath<T>;
fn deref(self: PendingStoragePath<T>) -> Self::Target {
self.as_path()
}
}
New storage design - examples
The new storage primitives that were discussed in the previous sections open new possibilities. We cover a few examples below.
+= for Storage variables
A common storage access pattern is reading a value, adding/subtracting, and then writing the new value. With StoragePath<T>
we can get the +=
functionality for every storage variable whose type has an AddAsign
implementation as follows:
#[starknet::contract]
mod numeric_contract {
use core::ops::AddAssign;
pub impl AddAsignStorage<
Lhs, Rhs, +Drop<Lhs>, +Drop<Rhs>,
+AddAssign<Lhs, Rhs>,
+Store<Lhs>
> of AddAssign<StorageBase<Mutable<Lhs>>, Rhs> {
fn add_assign(ref self: StorageBase<Mutable<Lhs>>, rhs: Rhs) {
let mut value: Lhs = self.read();
value += rhs;
self.write(value);
}
}
#[storage]
struct Storage {
numeric: u128,
}
#[abi(per_item)]
#[generate_trait]
pub impl NumericContract of NumericContractTrait {
#[external(v0)]
fn inc(ref self: ContractState) {
let mut num = self.numeric;
num += 1;
}
}
}
By implementing AddAsign
for StorageBase<Lhs>
we were able to get +=
for storage variables. Do note that this is not always desirable, since +=
might hide expensive operations (storage write).
Note that the above will not work for structs members, maps, and storage nodes, as those are not of type StorageBase<T>
. To extend +=
to all uints anywhere in storage, we need our impl to work on StoragePath
, or more specifically anything that implements the StorageAsPath
trait. This is illustrated below:
pub impl AddAsignStoragePath<
Lhs, Rhs, +Drop<Lhs>, +Drop<Rhs>,
+AddAssign<Lhs, Rhs>,
+Store<Lhs>
> of AddAssign<StoragePath<Mutable<Lhs>>, Rhs> {
fn add_assign(ref self: StoragePath<Mutable<Lhs>>, rhs: Rhs) {
let mut value: Lhs = self.read();
value += rhs;
self.write(value);
}
}
// Lhs is StorageBase<Mutable<u8>>
// LhsAsPath's value is Mutable<u8>
pub impl AddAsignStorageAsPath<
Lhs, Rhs, +Drop<Lhs>, +Drop<Rhs>,
impl LhsAsPath: StorageAsPath<Lhs>,
+AddAssign<StoragePath<LhsAsPath::Value>, Rhs>
> of AddAssign<Lhs, Rhs> {
fn add_assign(ref self: Lhs, rhs: Rhs) {
let mut value = self.as_path();
value += rhs;
}
}
#[starknet::storage_node]
struct Numeric {
num: u8,
map: Map<u8, u8>
}
fn foo(ref self: ContractState) {
let mut numeric = self.numeric.num;
numeric += 1;
let mut numeric = self.numeric.map.read(5);
numeric += 6;
}
To avoid the risk of having +=
for storage variables that may have large hidden cost, one can ignore the AddAssign
trait and implement a new trait, e.g. StorageInc
, with an inc
function that behaves similarly to the above
Giving access to storage variables
In previous versions, the only way to access storage outside the contract module was to use components or the unsafe_new_contract_state
function. With the new storage types, we can call functions that accept StoragePath<T>
, and thus allow them to modify our storage.
#[starknet::storage_node]
struct Numeric {
num: u8,
map: Map<u8, u8>
}
#[storage]
struct Storage {
numeric: Numeric,
map: Map<u8, u8>,
}
fn foo(ref self: ContractState) {
modify_from_outside(self.numeric.deref(), self.map.deref());
}
pub fn modify_from_outside(
numeric: StoragePath<Mutable<Numeric>>,
map: StoragePath<Mutable<Map<u8,u8>>>
) {
numeric.num.write(1);
map.entry(5).write(3);
}
Note that we had to call deref
explicitly to move from StorageBase
to StoragePath
.
This direction can yield a new way of writing components without much of the existing boilerplate. While still experimental, you can examine such an example in erc20_mini.cairo, and a contract using it in with_erc20_mini. With this approach, instead of using the component!
macro and denoting a storage member by #[substorage(v0)]
, you can define the component’s storage struct as a storage node.
Phantom types
Types annotated by #[phantom]
are types that cannot be instantiated. They are used to pass on information via traits that are implemented for them. For example, the following are phantom types:
#[phantom]
pub struct Map<K, V> {}
#[phantom]
pub struct Vec<T> {}
This shouldn’t be surprising as the above types are only used as the generic type of StoragePath
. The Storage
struct and any struct annotated by #[starknet::storage_node]
are also phantom types.
Associated items
Associated items are items that are declared in traits and defined in implementations. They are defined similarly to the same concept in Rust. We proceed to dive into each the possible associated items.
Associated types
The addition trait is a good example of the potential advantage of associated types:
trait Add<Lhs, Rhs> {
type Result;
fn add(lhs: Lhs, rhs: Rhs) -> Self::Result;
}
suppose now that a function foo<A, B>
needs the ability to add A and B. If we had defined the Add
trait with an additional generic parameter that is used to describe the result, then our code would have looked like this:
fn foo<A,B,C, +Add<A,B,C>>(a:A, b:B) -> C {
return a+b;
}
However, with associated types, we can get the result type from the impl of Add
, and we don’t need to pollute foo
with an additional generic argument:
fn foo<A,B, AddImpl: Add<A,B>>(a: A, b: B) -> AddImpl::Result {
return a+b;
}
The point is that foo
doesn’t necessarily need to generic on the addition result type, this information is associated with the impl of the Add
trait.
Associated consts
Suppose that we have a game with multiple character types, e.g. Wizard
and Warrior
, and each character has some fixed hp based on its type. We can model this scenario as follows:
#[derive(Drop)]
struct Warrior { ... }
#[derive(Drop)]
struct Wizard { ... }
trait Character<T> {
const hp: u32;
fn fight(self: T);
}
impl WizardCharacter of Character<Wizard> {
const hp: u32 = 70;
fn fight(self: Wizard) { ... }
}
impl WarriorCharacter of Character<Warrior> {
const hp: u32 = 100;
fn fight(self: Warrior) { ... }
}
Since hp is fixed per character type, associated costs allowed us to bind this number to the character trait rather than adding it to the struct or just hardcoding the value in the implementation.
Associated impls
The new iterator traits are a good example of when one might need associated impls. Consider the new iterator traits from iterator.cairo:
// T is the collection type
pub trait Iterator<T> {
type Item;
fn next(ref self: T) -> Option<Self::Item>;
}
/// Turn a collection of values into an iterator.
pub trait IntoIterator<T> {
/// The iterator type that will be created.
type IntoIter;
impl Iterator: Iterator<Self::IntoIter>;
fn into_iter(self: T) -> Self::IntoIter;
}
An implementation of IntoIterator
is expected to take a collection and return a corresponding iterator type IntoIter
. How can we enforce that the returned type is indeed an iterator, i.e. something that implements that iterator trait? This is where the associated impl Iterator
comes in, which is by definition an impl of the Iterator
trait for the associated IntoIter
iterator type.
The important observation is that any implementation of IntoIterator
should return an iterator. Thus, while we can use generic impl params to enforce a specific implementation of IntoIterator
returning an actual iterator, it should be already determined at the trait level. Associated impls are exactly the tool that allows us to do it.
Note that an implementation of IntoIterator
does not necessarily need to specify the Iterator
impl, if one exists in your context then it will be deduced, similarly to generic impl params. This is illustrated by the corelib’s implementation of IntoIterator<Array<T>>
:
#[derive(Drop)]
pub struct ArrayIter<T> {
array: Array<T>,
}
impl ArrayIterator<T> of Iterator<ArrayIter<T>> {
type Item = T;
fn next(ref self: ArrayIter<T>) -> Option<T> {
self.array.pop_front()
}
}
impl ArrayIntoIterator<T> of core::iter::IntoIterator<Array<T>> {
type IntoIter = ArrayIter<T>;
fn into_iter(self: Array<T>) -> ArrayIter<T> {
ArrayIter { array: self }
}
}
for loops
With iterators come for loops. You can now iterate over arrays and spans as follows:
fn test_for_loop_array_sum() {
let mut sum = 0;
for x in array![10, 11, 12] {
sum += x;
};
assert_eq!(sum, 33);
}
The above loop is based on the ArrayIntoIterator implementation in the corelib. You can find more examples in for_test.cairo.
SHA256
The sha256_process_block_syscall
system call is added. You’ll find high level functions for computing sha256 in sha256.cairo, specifically compute_sha256_u32_array
and compute_sha256_byte_array
.
Below is an example of computing sha256 in your contract:
#[external(v0)]
fn test_sha256(ref self: ContractState) {
let mut input: Array::<u32> = Default::default();
input.append('aaaa');
// Test the sha256 syscall computation of the string 'aaaa'.
let [res, _, _, _, _, _, _, _,] = compute_sha256_u32_array(input, 0, 0);
assert(res == 0x61be55a8, 'Wrong hash value');
}
Each syscall application costs ~ 180 gas. Taking padding into account, we need a syscall invocation per ~ 14 u32 inputs, resulting in ~ 3.2 gas per byte.
Default implementations
We can now have default implementations of trait functions, similarly to Rust. Default implementations are illustrated in the example below:
trait TraitWithDefaultImpl {
fn foo(self: u256);
fn boo(self: u128) -> u128 {
self+1
}
}
impl SomeImpl of TraitWithDefaultImpl {
fn foo(self: u256) {}
}
impl SomeOverridingImpl of TraitWithDefaultImpl {
fn foo(self: u256) {}
fn boo(self: u128) -> u128 { self }
}
SomeImpl::boo(3)
will use the default implementation, while SomeOverridingImpl::boo(3)
will use its own implementation.
Fixed size arrays
Fixed size arrays, denoted by [T; N]
, are a type that represents an N-tuple of T:
let arr1: [u64; 5] = [1,2,3,4,5];
let arr2: Span<u64> = [1,2,3,4,5].span();
To access a member of a fixed size array we can either deconstruct it with something like let [a, b, c, _, _] = arr1
, or use the .span()
function. Note that if we plan to repeatedly access the array, then it makes sense to call .span()
once and keep it available throughout the accesses.
With const fixed size arrays, we can hardcode a potentially long sequence of data in our program, similarly to how dw
(define word) was used in CairoZero:
const arr: [u64; 5] = [1,2,3,4,5];
To pass the const value cheaply between functions (without copying), you can call .span()
, which has a trivial implementation (no cairo-steps overhead)
fn get_arr(arr: Span<u64>) { ... }
get_arr(arr.span());
Arithmetic circuits
We introduce an efficient way to define and run arithmetic circuits with a 384-bit modulus. Arithmetic circuits in Cairo are aimed at applications that need to be as bare metal as possible, and want to avoid the overhead of Cairo by defining raw circuits. Two new builtins, add_mod
and mul_mod
, will perform arithmetic operations under the hood. That is, Cairo is only used to describe the circuit.
Four types of gates are supported, AddModGate
, SubModGate
, MulModGate
, and InverseGate
. To construct a circuit, we use the empty CircuitElement<T>
struct. The circuit description is encoded within the type T
. That is, whenever we add a gate to the circuit, we wrap our existing circuit types with the appropriate gate types. For example, adding CricuitElement<Lhs>
and CircuitElement<Rhs>
results with CircuitElement<AddModGate<Lhs, Rhs>>
.
The following code constructs the circuit: \frac{1}{x+y}\left((x+y)^{-1}-y\right):
use core::circuit::{
RangeCheck96, AddMod, MulMod, u96, CircuitElement, CircuitInput, circuit_add, circuit_sub,
circuit_mul, circuit_inverse, EvalCircuitTrait, u384, CircuitOutputsTrait, CircuitModulus,
AddInputResultTrait, CircuitInputs,
};
let in1 = CircuitElement::<CircuitInput<0>> {};
let in2 = CircuitElement::<CircuitInput<1>> {};
let add = circuit_add(in1, in2);
let inv = circuit_inverse(add);
let sub = circuit_sub(inv, in2);
let mul = circuit_mul(inv, sub);
Now, we need to define the output tuple:
let output_gates = (mul);
Note that any of the circuit’s gates can be added to the output tuple. We can access the value of any gate after evaluating, but outputs
must include all gates whose out degree is 0. The evaluation would have remained the same if we were do define output_gates
as (mul, add, sub, inv)
.
Next, we proceed to initialize the circuits with inputs, each input is represented by fixed size array of four u96:
output_gates.new_inputs()
.next([3, 0, 0, 0])
.next([6, 0, 0, 0])
.done()
Note that the next
function returns a variant of the AddInputResult
enum:
pub enum AddInputResult<C> {
/// All inputs have been filled.
Done: CircuitData<C>,
/// More inputs are needed to fill the circuit instance's data.
More: CircuitInputAccumulator<C>,
}
We must fill all the inputs of the circuit, and calling next
on the Done
variant, whose type CircuitData<C>
describes a circuit initialized with inputs, will result in a panic.
Now that we have CircuitData<C>
(C
is now the long type encoding the entire circuit), we can define the modules and call eval:
let modulus = TryInto::<_, CircuitModulus>::try_into([7, 0, 0, 0]).unwrap();
let res = output_gates.eval(modulos).unwrap();
Note that eval
returns Result
, as the evaluation may err due to division by zero. If the evaluation had been successful, we can ask the value of any intermediate gate, assuming we an instance of the corresponding type:
assert_eq!(res.get_output(add), u384 { limb0: 2, limb1: 0, limb2: 0, limb3: 0 });
assert_eq!(res.get_output(inv), u384 { limb0: 4, limb1: 0, limb2: 0, limb3: 0 });
assert_eq!(res.get_output(sub), u384 { limb0: 5, limb1: 0, limb2: 0, limb3: 0 });
assert_eq!(res.get_output(mul), u384 { limb0: 6, limb1: 0, limb2: 0, limb3: 0 });