Skip to content

Storing custom structs in Cairo 1

Posted on:29/05/2023
Originally published at blog.finiam.com

Storing custom structs in Cairo 1

Let’s imagine you are writing a Starknet smart contract in Cairo 1 and you want to store some complex data as the value of a map in your contract’s storage. You probably want to use a custom struct to save that complex data but you’ll find a small surprise.

Custom Struct

You might define your custom struct like this

#[derive(Drop, Serde)]
struct CustomStruct {
    a: u64,
    b: u64,
    c: u64,
}

and use it on the Storage struct like so

struct Storage {
    map: LegacyMap<felt252, CustomStruct>,
}

So far, so good. This makes perfect sense and it’s the correct way of doing it. However, depending on your Cairo version, and at the time of writing, when you attempt to compile your contract, you might get the following error.

Detailed error information: error: Trait has no implementation in context: core::starknet::storage_access::StorageAccess::<hello_starknet::contracts::hello_starknet::HelloStarknet::CustomStruct>
 --> contract:76:54
            starknet::StorageAccess::<CustomStruct>::read(
                                                     ^**^

error: Trait has no implementation in context: core::starknet::storage_access::StorageAccess::<hello_starknet::contracts::hello_starknet::HelloStarknet::CustomStruct>
 --> contract:84:54
            starknet::StorageAccess::<CustomStruct>::write(
                                                     ^***^

What’s the problem here? What this means is simply the StorageAccess Trait has no implementation of the write and read functions needed to access your CustomStruct in the Storage struct. For native types, this is automatically derived but since you defined your own CustomStruct, the compiler has no idea what to do.

Note: this is a temporary problem and in newer versions of Cairo we no longer need to manually derive the implementation. This PR adds that functionality for custom structs used in Storage and is already available if you are using the main branch of the Cairo compiler.

Implementation

So, while we wait for a proper release to include this functionality, how can we fix this? Let’s take a look at a possible implementation.

We start by defining a new implementation of the StorageAccess for our CustomStruct.

impl CustomStructStorageAccess of StorageAccess::<CustomStruct> {}

Inside, we define two functions, a write function and a read function. As you can see from the signatures, the write funtion receives, amongst other parameters, a CustomStruct that we want to save in our Storage space, and the read function returns a stored CustomStruct wrapped on a SyscallResult.

fn write(address_domain: u32, base: StorageBaseAddress, value: CustomStruct) -> SyscallResult::<()> {}
fn read(address_domain: u32, base: StorageBaseAddress) -> SyscallResult::<CustomStruct> {}

Write

A write function implementation could look something like this. We receive a CustomStruct and we make use of the storage_write_syscall function to write each individual value to our storage space.

fn write(address_domain: u32, base: StorageBaseAddress, value: CustomStruct) -> SyscallResult::<()> {
  storage_write_syscall(
    address_domain,
    storage_address_from_base_and_offset(base, 0_u8),
    value.value_a.into()
  );

  storage_write_syscall(
    address_domain,
    storage_address_from_base_and_offset(base, 1_u8),
    value.value_b.into()
  );

  storage_write_syscall(
    address_domain,
    storage_address_from_base_and_offset(base, 2_u8),
    value.value_c.into()
  )
}

One thing to take into consideration is the use of the storage_address_from_base_and_offset function. This function is responsible for generating the address of our storage space where we are saving our element. Since our struct has three elements, the second parameter of the function goes from 0_u8 to 2_u8, so that we have a unique spot for each element.

Read

The read function is what we need in order to retrieve the stored data of our CustomStruct. This function makes use of the storage_read_syscall function to retrieve each of our elements from the storage space. We also need to provide the same offset as we did for the write function and we simply construct a CustomStruct instance with the data retrieved. We wrap our struct in a Result::Ok due to the function signature expected from the StorageAccess trait.

One possible implementation would be something like this.

fn read(address_domain: u32, base: StorageBaseAddress) -> SyscallResult::<CustomStruct> {
  Result::Ok(
    CustomStruct {
      value_a: storage_read_syscall(
        address_domain,
        storage_address_from_base_and_offset(base, 0_u8)
      )?.try_into().expect('not u64'),
      value_b: storage_read_syscall(
        address_domain,
        storage_address_from_base_and_offset(base, 1_u8)
      )?.try_into().expect('not u64'),
      value_c: storage_read_syscall(
        address_domain,
        storage_address_from_base_and_offset(base, 2_u8)
      )?.try_into().expect('not u64'),
    }
  )
}

Wrapping up

After adding this implementation, our contract should compile without any problem. For reference, here is the full implementation of a contract that uses a custom struct using this manual implementation approach.

Once again, this is only needed at the time of writing, and if we are using tagged release versions. On the main branch, we no longer need to provide the impl block to use this functionality, simply defining the struct is enough.

That’s pretty much it. This was just a short post to show how we can manually implement the read and write function for custom defined types.

Until next time!