En Starknet, el Contract Storage (almacenamiento del contrato) es la memoria persistente donde reside el estado de tu contrato inteligente. A diferencia de las variables declaradas dentro de una función, que desaparecen tras la ejecución, los datos en el almacenamiento permanecen en la blockchain permanentemente.
Sin embargo, simplemente declarar una variable no es suficiente. Para interactuar con el almacenamiento del contrato de manera efectiva en Cairo, el compilador necesita dos piezas de lógica:
- Representación de datos: Cómo serializar y deserializar el tipo de datos para el almacenamiento. Esto es manejado por el trait
starknet::Store. - Lógica de acceso: Cómo leer o escribir realmente en la ranura (slot) de almacenamiento específica. Esto es manejado por un conjunto de Access Traits (traits de acceso).
Para tipos como enteros, bool, felt252, ByteArray, y demás, Cairo ya proporciona una implementación del trait starknet::Store. Como resultado, estos tipos se pueden usar directamente en el almacenamiento del contrato sin ningún trabajo adicional. Por ejemplo, en el contrato a continuación, tanto felt252 como u256 son miembros de almacenamiento válidos porque ya implementan el trait.
#[storage]
struct Storage {
num1: felt252,
num2: u256,
}
Sin embargo, cuando se trata con tipos complejos en el almacenamiento, como mappings, arrays o structs definidos por el usuario, debemos derivar el trait o usar un tipo especial proporcionado por Cairo para representar nuestro tipo en el almacenamiento. Estos casos se discutirán en detalle en secciones posteriores.
Este artículo cubrirá los diferentes tipos que se pueden usar en el almacenamiento y los traits que cada tipo requiere para ser utilizado en el almacenamiento.
Storage Access Traits
Los traits de acceso determinan cómo se leen o escriben los valores en el almacenamiento en función del tipo al que se accede. Cairo utiliza diferentes traits de acceso internamente para resolver estas operaciones dependiendo de si estamos interactuando con los tipos que ya implementan el trait starknet::Store o con los tipos especiales.
Aquí hay un desglose rápido de estos traits de acceso; entraremos en detalles más adelante:
StoragePointerReadAccessyStoragePointerWriteAccess: Utilizados para leer y escribir valores en tipos simples o structs personalizados que implementanstarknet::Store.StorageMapReadAccessyStorageMapWriteAccess: Manejan la lectura y escritura en tipos mapping (clave-valor) en el almacenamiento.StoragePathEntry: Ayuda a resolver el acceso a mappings anidados.VecTraityMutableVecTrait: Proporcionan acceso a arrays dinámicos en un almacenamiento.
Ahora que sabemos que cualquier tipo utilizado en el almacenamiento de Cairo debe implementar el trait starknet::Store y que leer o escribir en él requiere importar el trait de acceso apropiado, veamos qué tipos ya implementan el trait starknet::Store antes de pasar a cómo funcionan las lecturas y escrituras a nivel de contrato.
Types that Implement starknet::Store Trait
Los siguientes tipos de Cairo implementan el trait starknet::Store:
- felt252
- enteros con y sin signo (unsigned and signed integers)
- bool
- bytes31
- ByteArray
- ContractAddress
- Tuple
Dado que los tipos anteriores ya implementan el trait starknet::Store, leer y escribir en el almacenamiento solo requiere importar los traits de acceso necesarios para hacer que los métodos read() y write() estén disponibles.
Considera un contrato que declara varias variables de estado utilizando los tipos enumerados anteriormente. El fragmento a continuación muestra cómo se puede declarar cada tipo en el almacenamiento:
#[starknet::contract]
mod HelloStarknet {
use starknet::ContractAddress;
#[storage]
struct Storage {
// felt252: Field element
user_id: felt252,
// u256: 256-bit unsigned integer
total_supply: u256,
// bool: Boolean value for true/false conditions
is_paused: bool,
// bytes31: Fixed-size byte array (31 bytes), for storing short strings/data
contract_name: bytes31,
// ByteArray: for storing long strings
contract_description: ByteArray,
// ContractAddress: Starknet contract address type
owner_address: ContractAddress,
// Tuple: Groups multiple values together
version_info: (u8, i8) // (unsigned integer, signed integer)
}
}
Ahora que hemos visto cómo se pueden declarar diferentes tipos de datos en el almacenamiento de Cairo, el siguiente paso es entender cómo trabajar con ellos, es decir, cómo escribir valores realmente en el almacenamiento y luego leerlos de vuelta.
Write Operation
Antes de que podamos escribir en cualquiera de las variables de estado declaradas anteriormente, primero debemos importar el trait StoragePointerWriteAccess. Este trait habilita el método .write(value) en las variables de almacenamiento, lo que nos permite asignar valores directamente a través de sus punteros de almacenamiento.
Está disponible en el módulo starknet::storage:
// Import `StoragePointerWriteAccess` trait
use starknet::storage::StoragePointerWriteAccess;
A continuación, se muestra cómo realizar operaciones de escritura en diferentes tipos simples (el código recién agregado está anotado con el comentario /* NEWLY ADDED */):
#[starknet::contract]
mod HelloStarknet {
use starknet::ContractAddress;
// Import `StoragePointerWriteAccess` trait
use starknet::storage::StoragePointerWriteAccess; /* NEWLY ADDED */
#[storage]
struct Storage {
user_id: felt252,
total_supply: u256,
is_paused: bool,
contract_name: bytes31,
contract_description: ByteArray,
owner_address: ContractAddress,
version_info: (u8, i8)
}
/* NEWLY ADDED */
#[abi(embed_v0)]
impl HelloStarknetImpl of super::IHelloStarknet<ContractState> {
fn write_vars(ref self: ContractState) {
// Writing to felt252
self.user_id.write(12345);
// Writing to u256
self.total_supply.write(1000000_u256);
// Writing to bool
self.is_paused.write(false);
// Writing to bytes31 (short string)
self.contract_name.write('HelloContract'.try_into().unwrap());
// Writing to ByteArray (long string)
self.contract_description.write("This is a very very very long textttt");
// Writing to ContractAddress
self.owner_address.write(0x1234.try_into().unwrap());
// Writing to tuple
self.version_info.write((1_u8, -2_i8));
}
}
}
Read Operations
Para las operaciones de lectura, necesitamos importar el trait StoragePointerReadAccess, lo que nos permite usar el método .read() en los tipos declarados anteriormente:
use starknet::storage::StoragePointerReadAccess;
Ampliando el contrato de la sección anterior, el código a continuación importa el trait StoragePointerReadAccess y lee el valor de las variables de estado (el código recién agregado está anotado con el comentario /* NEWLY ADDED */):
#[starknet::contract]
mod HelloStarknet {
use starknet::ContractAddress;
// Import `StoragePointerWriteAccess` trait
use starknet::storage::{
StoragePointerWriteAccess,
/* NEWLY ADDED */
StoragePointerReadAccess,
};
#[storage]
struct Storage {
user_id: felt252,
total_supply: u256,
is_paused: bool,
contract_name: bytes31,
contract_description: ByteArray,
owner_address: ContractAddress,
version_info: (u8, i8)
}
#[abi(embed_v0)]
impl HelloStarknetImpl of super::IHelloStarknet<ContractState> {
fn write_vars(ref self: ContractState) {
self.user_id.write(12345);
self.total_supply.write(1000000_u256);
self.is_paused.write(false);
self.contract_name.write('HelloContract'.try_into().unwrap());
self.contract_description.write("This is a very very very long textttt");
self.owner_address.write(0x1234.try_into().unwrap());
self.version_info.write((1_u8, -2_i8));
}
/* NEWLY ADDED */
fn read_vars(self: @ContractState) {
// felt252: Reading user ID returns a field element (0 to P-1 range)
let _ = self.user_id.read();
// u256: Reading large integer, useful for token balances and big numbers
let _ = self.total_supply.read();
// bool: Reading boolean state, returns true or false
let _ = self.is_paused.read();
// bytes31: Reading fixed-size byte array, used for short strings
let _ = self.contract_name.read();
// ByteArray: Reading dynamic-size byte array, used for long strings
let _ = self.contract_description.read();
// ContractAddress: Reading Starknet address, type-safe contract/user address
let _ = self.owner_address.read();
// Tuple: Reading compound type returns both values as (u8, i8) pair
let _ = self.version_info.read();
}
}
}
Mapping and Vec
Los tipos de colección como diccionarios y arrays no se pueden almacenar directamente en el almacenamiento de contratos de Cairo. Esto se debe a que utilizan diseños de memoria dinámica que el sistema de almacenamiento no admite por defecto. En su lugar, Cairo proporciona tipos especializados para trabajar con colecciones en el almacenamiento: Map y Vec.
Estos tipos especiales están disponibles en el módulo starknet::storage y se utilizan para declarar mappings y vectores en el almacenamiento del contrato. Antes de que podamos usar cualquiera de ellos en nuestro struct Storage, necesitaremos importar explícitamente estos tipos especiales desde la biblioteca principal, como se muestra a continuación:
use starknet::storage::{ Map, Vec };
Ten en cuenta que
MapyVecno necesitan ser importados juntos; puedes importar solo el que necesites, dependiendo de tu caso de uso. Por ejemplo, si tu contrato solo requiere mappings, está bien importar solo el tipoMap.
Una vez que hayamos importado Map y Vec, podemos usarlos dentro del struct de almacenamiento, de la siguiente manera:
use starknet::storage::{ Map, Vec };
#[storage]
struct Storage {
// mapping(address => uint256) my_map;
my_map: Map<ContractAddress, u256>,
// uint64[] my_vec;
my_vec: Vec<u64>,
}
El equivalente de cómo declaramos ambos tipos Map y Vec en Solidity está comentado sobre cada declaración en el código anterior. El tipo Map toma dos parámetros genéricos: el KeyType y el ValueType. En nuestro ejemplo, ContractAddress es la clave y u256 es el valor, lo que significa que este map almacena una cantidad de u256 para cada dirección. El tipo Vec, por otro lado, toma un solo tipo y representa un array de elementos de ese tipo. En nuestro ejemplo, es un array de enteros sin signo de 64 bits.
Ten en cuenta que no hay un tipo de almacenamiento tradicional de “array fijo” en Cairo como tenemos en Solidity y otros lenguajes.
Con estas variables de estado configuradas, veamos cómo interactuar con ellas usando operaciones de lectura y escritura.
Read and Write Operations on the Map Type
En nuestro ejemplo, my_map representa un mapping de una dirección a un valor u256, de manera similar a cómo definiríamos mapping(address => uint256) en Solidity.
my_map: Map<ContractAddress, u256>
Antes de realizar estas operaciones, primero debemos importar los traits de acceso necesarios que permiten leer y escribir en el almacenamiento. También están disponibles en el módulo starknet::storage.
#[starknet::contract]
mod HelloStarknet {
// IMPORT MAP TYPE AND NECESSARY ACCESS TRAITS
use starknet::storage::{
Map,
StorageMapWriteAccess, // Enables .write(key, value) operations
StorageMapReadAccess, // Enables .read(key) operations
};
}
Estos traits habilitan los métodos como .write(key, value) y .read(key), que usaremos en los próximos ejemplos. Sin importarlos, no podremos realizar ninguna de estas operaciones en nuestra colección de almacenamiento.
Con eso en su lugar, ahora podemos implementar las operaciones de escritura y lectura para el tipo Map.
Operación de escritura
Usamos el método .write(key, value) proporcionado por StorageMapWriteAccess. La sintaxis es directa:
self.my_map.write(key, value);
Esto es lo que hace cada parte:
self.my_mapse refiere alMapdeclarado en el struct de almacenamiento..write(...)es el método que actualiza el map.keyes el identificador (en nuestro caso, unContractAddress) bajo el cual se almacenará el valor.valueson los datos reales (en nuestro caso, unu256) que se guardan.
Cada llamada a write() insertará un nuevo par clave-valor o sobrescribirá el valor existente si la clave ya existe.
A continuación se muestra un ejemplo de cómo usar .write() en una función:
#[starknet::contract]
mod HelloStarknet {
//...
#[abi(embed_v0)]
impl HelloStarknetImpl of super::IHelloStarknet<ContractState> {
fn write_to_mapping(
ref self: ContractState,
user: ContractAddress,
amount: u256
) {
self.my_map.write(user, amount); // write operation
}
}
}
Esto almacena la cantidad (amount) para la dirección del usuario (user) dada. Internamente, Cairo se encarga de escribir esto en la ranura de almacenamiento adecuada según la clave.
Operación de lectura
Usamos el método .read(key) proporcionado por StorageMapReadAccess. La sintaxis se ve así:
self.my_map.read(key);
Aquí hay un desglose de lo que hace:
self.my_mapse refiere a la instancia del map en el struct de almacenamiento..read(...)accede al valor almacenado.keyes el identificador que queremos buscar; en nuestro caso, unContractAddress, ya que lo usamos como la clave del map.
El método read() devuelve el valor asociado con la clave. Si la clave no ha sido escrita anteriormente, devuelve el valor predeterminado para el valueType del map (por ejemplo, 0 para u256).
Ejemplo:
#[starknet::contract]
mod HelloStarknet {
// ...
#[abi(embed_v0)]
impl HelloStarknetImpl of super::IHelloStarknet<ContractState> {
fn get_value(self: @ContractState, user: ContractAddress) -> u256 {
self.my_map.read(user) // read operation
}
}
}
Esta función lee el valor almacenado para una dirección user dada y lo devuelve.
Nested Maps Operations
Leer o escribir en un mapping anidado en el almacenamiento requiere un trait de acceso adicional llamado StoragePathEntry. Este trait habilita el método .entry(key), que proporciona acceso a los maps internos almacenados bajo una clave dada.
En otras palabras, cuando tratamos con un mapping anidado donde el valor en sí es un Map, no podemos acceder a él directamente con .read() o .write(). En su lugar, primero debemos llamar a .entry(key) para llegar a la capa interna y luego realizar operaciones en ella.
Declarar un mapping anidado
Declaremos nuestro mapping anidado en el almacenamiento. Este será un map de dos niveles donde el map externo usa un ContractAddress (una dirección de usuario) como clave, el map interno también usa un ContractAddress (una dirección de token) como clave y el valor almacenado es un u256 que representa un saldo de token:
#[storage]
struct Storage {
// user_address => token_address => balance
two_level_mapping: Map<ContractAddress, Map<ContractAddress, u256>>,
}
Importación del trait requerido
use starknet::storage::StoragePathEntry;
StoragePathEntry habilita el método .entry(key) para obtener la ruta de almacenamiento de la siguiente clave en la secuencia.
Aunque .entry() nos da acceso a la capa anidada, no es suficiente por sí solo para realizar operaciones de lectura o escritura. Todavía necesitamos importar los traits que habilitan esos métodos específicos. Los traits exactos que necesitaremos dependen de cómo estemos realizando estas operaciones.
Veremos dos formas de leer y escribir en mappings anidados en el almacenamiento.
Operaciones de escritura y lectura
Método 1: Encadenar siempre .entry() para N capas (hasta llegar al valor)
Este enfoque encadena múltiples llamadas a .entry() a través de cada capa del map, hasta que llega a la ranura de almacenamiento. Luego, usa .write(value) o .read() para interactuar directamente con el valor almacenado.
#[starknet::contract]
mod HelloStarknet {
use starknet::ContractAddress;
// IMPORT TRAITS
use starknet::storage::{
Map,
StoragePathEntry, // Enables .entry(key)
/* ADDITIONAL TRAITS */
StoragePointerWriteAccess, // Enables .write(value)
StoragePointerReadAccess, // Enables .read()
};
#[storage]
struct Storage {
// user_address => token_address => balance
two_level_mapping: Map<ContractAddress, Map<ContractAddress, u256>>,
}
#[abi(embed_v0)]
impl HelloStarknetImpl of super::IHelloStarknet<ContractState> {
fn write_nested_map(
ref self: ContractState,
key1: ContractAddress,
key2: ContractAddress,
value: u256,
) {
// WRITE OPERATION
self.two_level_mapping.entry(key1).entry(key2).write(value);
}
fn read_nested_map(
ref self: ContractState,
key1: ContractAddress,
key2: ContractAddress,
) -> u256 {
// READ OPERATION
self.two_level_mapping.entry(key1).entry(key2).read()
}
}
}
Para las operaciones de lectura y escritura, ambas funciones usan el método .entry() dos veces:
- El primer
.entry(key1)accede al map externo. - El segundo
.entry(key2)profundiza en el map interno para llegar a una ranura de almacenamiento específica.
Una vez que estamos en esa ubicación de almacenamiento exacta, usamos:
.write(value)habilitado por el traitStoragePointerWriteAccesspara escribir un valor directamente en esa ranura..read()habilitado por el traitStoragePointerReadAccesspara leer el valor de esa ranura.
Método 2: Encadenar .entry() para N-1 capas (deteniéndose en el map más interno)
Este enfoque profundiza a través de cada capa del map utilizando el método .entry() hasta llegar al mapping interno, luego lo trata como un todo e interactúa con él directamente utilizando .write(key, value) y .read(key).
#[starknet::contract]
mod HelloStarknet {
use starknet::ContractAddress;
// IMPORT TRAITS
use starknet::storage::{
Map,
StoragePathEntry, // Enables .entry(key) method
/* ADDITIONAL TRAITS */
StorageMapWriteAccess, // Enables .write(key, value) method
StorageMapReadAccess, // Enables .read(key) method
};
#[storage]
struct Storage {
// user_address => token_address => balance
two_level_mapping: Map<ContractAddress, Map<ContractAddress, u256>>,
}
#[abi(embed_v0)]
impl HelloStarknetImpl of super::IHelloStarknet<ContractState> {
fn write_nested_map(
ref self: ContractState,
key1: ContractAddress,
key2: ContractAddress,
value: u256,
) {
// WRITE OPERATION
self.two_level_mapping.entry(key1).write(key2, value);
}
fn read_nested_map(
ref self: ContractState,
key1: ContractAddress,
key2: ContractAddress,
) -> u256 {
// READ OPERATION
self.two_level_mapping.entry(key1).read(key2)
}
}
}
Para las operaciones de lectura y escritura, cada función en el código anterior usa el método .entry(key1) una vez:
- Este
.entry(key1)da acceso al map interno.
Una vez que tenemos una referencia a ese map interno, lo tratamos como un Map regular:
.write(key2, value)habilitado por el traitStorageMapWriteAccessalmacena el valor bajo lakey2..read(key2)habilitado por el traitStorageMapReadAccessrecupera el valor almacenado bajo lakey2.
Este método trata el valor interno como un map completo, no como una sola ranura de almacenamiento.
Ambos métodos son válidos; el desarrollador simplemente necesita importar los traits apropiados en función de cómo planee interactuar con el mapping anidado.
A continuación, exploraremos cómo realizar operaciones similares en el tipo Vec, incluyendo la inserción de nuevos elementos y la lectura desde índices específicos.
Read and Write Operations on the Vec Type
El tipo Vec en Cairo se utiliza para representar un array expansible en el almacenamiento del contrato, de manera similar a los arrays dinámicos en Solidity como uint64[]. Soporta operaciones comunes como añadir nuevos elementos, acceder a ítems por índice y eliminar elementos del final del array.
Para continuar con nuestro ejemplo, interactuaremos con el tipo Vec en nuestra declaración de almacenamiento:
#[storage]
struct Storage {
// Solidity equivalent: uint64[] my_vec;
my_vec: Vec<u64>,
}
Pero antes de eso, tenemos dos traits asociados con el tipo Vec: VecTrait y MutableVecTrait.
VecTrait proporciona métodos de solo lectura para interactuar con vectores en el almacenamiento. Esto incluye:
.len(): devuelve el número actual de elementos en el vector. Su tipo de retorno esu64..get(index): devuelve de forma segura un puntero al elemento en el índice dado. DevuelveNonesi el índice está fuera de los límites..at(index): devuelve un puntero al elemento en el índice dado, pero produce un panic si el índice es inválido.
MutableVecTrait extiende VecTrait agregando métodos de mutación que te permiten modificar el contenido del vector en el almacenamiento. Estos incluyen:
.push(value): añade un nuevo elemento al final del vector..pop(): elimina y devuelve el último elemento, o devuelveNonesi el vector está vacío..allocate(): reserva espacio para un nuevo elemento al final del vector y devuelve un puntero escribible, útil para tipos complejos o anidados.
Dependiendo de la operación del vector, es posible que también necesitemos importar traits de acceso como StoragePointerWriteAccess o StoragePointerReadAccess.
En las siguientes subsecciones, veremos ejemplos de operaciones comunes como agregar, leer, actualizar y eliminar elementos utilizando estos traits.
Hacer push (añadir) un nuevo valor al vector my_vec
El método push utilizado en la función push_number incrementa primero la longitud del vector y luego escribe el valor en una nueva ranura de almacenamiento al final del vector.
// IMPORT TRAITS
use starknet::storage::{Vec, MutableVecTrait};
fn push_number(ref self: ContractState, value: u64) {
// PUSH OPERATION
self.my_vec.push(value);
}
Leer de un índice existente
Si queremos recuperar un valor en un índice existente, podemos usar .get() o .at() para obtener el puntero, y luego usar .read() para leer su valor:
// IMPORT TRAITS
use starknet::storage::{Vec, MutableVecTrait, StoragePointerReadAccess};
fn read_my_vec(self: @ContractState, index: u64) -> u64 {
// VEC READ OPERATION
self.my_vec.at(index).read() // Will panic if index is out of bounds
}
Ejercicio: ¿Por qué agregamos el trait StoragePointerReadAccess?
Actualizar un valor en un índice existente
Para actualizar un valor en un índice existente, podemos usar .get() o .at() para obtener el puntero, y luego usar .write(value) para modificar su valor:
// IMPORT TRAITS
use starknet::storage::{Vec, MutableVecTrait, StoragePointerWriteAccess};
fn write_my_vec(ref self: ContractState, index: u64, val: u64) -> u64 {
// VEC WRITE OPERATION
self.my_vec.at(index).write(val) // Will panic if index is out of bounds
}
Obtener la longitud del vector
.len() devuelve el número actual de elementos en el vector como un u64.
// IMPORT TRAITS
use starknet::storage::{Vec, MutableVecTrait};
fn get_vec_len(self: @ContractState) -> u64 {
// RETURN VEC LENGTH
self.my_vec.len()
}
Hacer pop (eliminar) el último elemento
use starknet::storage::{Vec, MutableVecTrait};
fn pop_last(ref self: ContractState) {
// POP OPERATION
let _ = self.my_vec.pop();
}
.pop() recupera el valor almacenado en la última posición del vector, disminuye la longitud del vector y luego devuelve el valor recuperado o None si el vector está vacío.
Struct and Enum Type in Storage
A diferencia de los tipos que implementan el trait starknet::Store por defecto (u8, bool, felt252, etc.), los structs requieren que derives explícitamente el trait; de lo contrario, cualquier intento de usar ese struct en el almacenamiento fallará en tiempo de compilación.
Para leer o escribir un struct en el almacenamiento, este debe implementar las funciones de lectura y escritura necesarias, que es lo que hace automáticamente el trait starknet::Store.
Para que sea posible almacenar un struct en el almacenamiento de Cairo, necesitaremos derivar el trait agregando este atributo #[derive(starknet::Store)] por encima de su definición:
#[derive(starknet::Store)]
struct User {
id: u32,
name: bytes31,
is_admin: bool,
}
Una vez hecho esto, el struct se puede usar en operaciones relacionadas con el almacenamiento, incluso dentro del struct #[storage] como tipo en mappings y arrays.
A continuación hay un contrato de ejemplo que demuestra cómo declarar un struct personalizado en el almacenamiento, importar los traits necesarios y realizar operaciones de lectura y escritura en ese struct.
#[starknet::contract]
mod HelloStarknet {
// IMPORT TRAITS
use starknet::storage::{
StoragePointerReadAccess, // Enables .read()
StoragePointerWriteAccess // Eabales .write(value)
};
// CUSTOM STRUCT DEFINITION
#[derive(starknet::Store)]
struct UserData {
id: u32,
name: bytes31,
is_admin: bool,
}
#[storage]
struct Storage {
// CUSTOM STRUCT DECLARATION
user: UserData,
}
#[abi(embed_v0)]
impl HelloStarknetImpl of super::IHelloStarknet<ContractState> {
// WRITE OPERATION
fn write_struct(ref self: ContractState, _id: u32, _name: bytes31, _is_admin: bool) {
self.user.id.write(_id); // Write to field 1
self.user.name.write(_name); // Write to field 2
self.user.is_admin.write(_is_admin); // Write to field 3
}
// READ OPERATION
fn read_struct(ref self: ContractState) -> (u32, bytes31, bool) {
let id = self.user.id.read(); // Read from field 1
let name = self.user.name.read(); // Read from field 2
let is_admin = self.user.is_admin.read(); // Read from field 3
(id, name, is_admin)
}
}
}
Observa que importamos los siguientes traits:
use starknet::storage::{
StoragePointerReadAccess, // Enables .read() on storage paths
StoragePointerWriteAccess // Enables .write(value) on storage paths
};
Importamos estos traits porque los campos del struct son tipos simples, y sin ellos, llamadas como .read() y .write(value) no compilarían.
Operación de escritura
Dentro de la función write_struct:
// WRITE OPERATION
fn write_struct(
ref self: ContractState,
_id: u32,
_name: bytes31,
_is_admin: bool
) {
self.user.id.write(_id); // Write to field 1
self.user.name.write(_name); // Write to field 2
self.user.is_admin.write(_is_admin); // Write to field 3
}
Cada llamada escribe un nuevo valor en un campo específico dentro del struct almacenado. Esto muestra que, aunque el struct se almacena como un solo objeto, a sus campos se puede acceder y se pueden actualizar de forma independiente.
Operación de lectura
Esta función read_struct lee cada campo individualmente y los devuelve como una tupla:
fn read_struct(ref self: ContractState) -> (u32, bytes31, bool) {
let id = self.user.id.read(); // Read from field 1
let name = self.user.name.read(); // Read from field 2
let is_admin = self.user.is_admin.read(); // Read from field 3
(id, name, is_admin)
}
Enum Type
Los enums siguen un patrón similar a los structs, debemos derivar explícitamente starknet::Store para que puedan ser almacenados. Cada tipo de variante también debe implementar el trait starknet::Store. Además, dado que los enums pueden contener datos que requieren una limpieza adecuada cuando los valores son reemplazados o eliminados, también necesitamos derivar el trait Drop.
Aquí hay un ejemplo básico de cómo definir un enum y usarlo en el almacenamiento:
#[starknet::contract]
mod HelloStarknet {
// DEFINE ENUM
#[derive(starknet::Store, Drop)]
enum UserRole {
Admin,
Mod,
#[default]
User,
}
#[storage]
struct Storage {
// DECLARE ENUM
my_role: UserRole,
}
}
En nuestra definición del enum, incluimos el atributo #[default], el cual es requerido para cualquier enum que se vaya a utilizar en el almacenamiento. Este atributo marca una de las variantes como el valor por defecto (en nuestro caso, la variante User) que se asigna cuando el valor de almacenamiento no ha sido establecido.
Operaciones de escritura y lectura en Enum
El siguiente código importa los traits de acceso requeridos para leer y escribir un enum en el almacenamiento, seguido de dos funciones que realizan estas operaciones:
// IMPORT TRAITS
use starknet::storage::{
StoragePointerReadAccess, // Enables .read() on enum stored in storage
StoragePointerWriteAccess, // Enables .write(value) on enum stored in storage
};
// WRITE OPERATION
fn write_enum(ref self: ContractState) {
// Write the Admin variant to storage
self.my_role.write(UserRole::Admin);
}
// READ OPERATION
fn read_enum(self: @ContractState) {
// Read the current value of the enum from storage
let _ = self.my_role.read();
}
En Cairo, los tipos de colección como Vec o Map no se pueden incluir como campos en un struct o como variantes en un enum porque dependen de diseños de memoria dinámica que el sistema de almacenamiento no admite por defecto.
// STRUCT: This will NOT work ❌ - Vec has dynamic size
#[derive(starknet::Store)]
struct InvalidUser {
name: felt252,
balance: u256,
friends: Vec<ContractAddress>, // ERROR: Cannot store Vec in struct
tokenBal: Map<ContractAddress, u256>, // ERROR: Cannot store Map in struct
}
// ENUM: This will also NOT work ❌ - Map has dynamic size
#[derive(starknet::Store)]
enum InvalidUserRole {
Admin: Map<felt252, bool>, // ERROR: Cannot store Map in enum variant
#[default]
User,
}
Si necesitamos almacenar colecciones dentro de un struct, tenemos que usar un tipo especial de struct llamado storage node.
Storage Nodes
Los storage nodes (nodos de almacenamiento) siguen siendo structs, pero con una diferencia clave: pueden contener tipos de colección dinámicos como Vec y Map. A diferencia de los structs regulares definidos por el usuario que discutimos anteriormente, los cuales no soportan colecciones, los storage nodes están diseñados específicamente para manejarlas, haciéndolos ideales para gestionar datos anidados o dinámicos en el almacenamiento.
Defining Storage Nodes
Para definir un storage node, usamos el atributo #[starknet::storage_node] en lugar de las derivaciones de struct regulares:
// Storage node - CAN contain collections
#[starknet::storage_node]
struct UserStorageNode {
name: felt252,
balance: u256,
friends: Vec<ContractAddress>, // ✅ Now allowed!
tokenBal: Map<ContractAddress, u256>, // ✅ Also allowed!
}
El atributo #[starknet::storage_node] permite el soporte de tipos de colección y maneja automáticamente la lógica de almacenamiento necesaria.
Using Storage Nodes
Una vez definidos, los storage nodes se pueden declarar como cualquier otro tipo dentro del struct #[storage]. Por ejemplo, declararemos una variable de almacenamiento user_data con el tipo de storage node que definimos arriba (UserStorageNode), de la siguiente manera:
#[storage]
struct Storage {
user_data: UserStorageNode,
}
A continuación, mostraremos cómo inicializar la variable de almacenamiento user_data y también cómo leer de ella.
Storage Node Operations
Escribir en Storage Nodes
Para escribir en los storage nodes, accedemos a sus campos directamente a través de la variable de almacenamiento (en nuestro caso, user_data), y luego usamos los métodos de almacenamiento apropiados como .write(key, value), .push(), o .entry(key).write(value) dependiendo del tipo de campo, como se muestra a continuación:
#[abi(embed_v0)]
impl HelloStarknetImpl of super::IHelloStarknet<ContractState> {
fn write_nodes(ref self: ContractState) {
// Write to simple fields (felt252 and u256)
self.user_data.name.write(3);
self.user_data.balance.write(1000_u256);
// Push a new address to the friends vector
self.user_data.friends.push(get_caller_address());
// Write to nested map using either of the two valid approaches
// Approach 1
self.user_data.tokenBal.entry(get_caller_address()).write(23);
// Approach 2
self.user_data.tokenBal.write(get_caller_address(), 23);
}
}
Leer de Storage Nodes
Leer de los storage nodes sigue patrones similares: accedemos a cada campo directamente y llamamos a .read() para valores simples, a .at(index) para un elemento de vector específico, o a .read(key) para un mapping.
#[abi(embed_v0)]
impl HelloStarknetImpl of super::IHelloStarknet<ContractState> {
fn read_nodes(self: @ContractState) {
// Read simple values
let _ = self.user_data.name.read();
let _ = self.user_data.balance.read();
// Read a value from the vector at index 0
let _ = self.user_data.friends.at(0);
// Read token balance from the nested map
let _ = self.user_data.tokenBal.read(get_caller_address());
}
}
Ejercicio: Al observar las operaciones de lectura y escritura en los storage nodes anteriores, enumera los traits necesarios para realizar las siguientes operaciones:
.write(value).push(value).entry(key).write(key, value).read().at(index).read(key)
Conclusion
Para resumir, aquí hay un resumen de los traits de acceso a almacenamiento más comúnmente utilizados en Cairo. Cada trait habilita métodos específicos que nos permiten interactuar con tipos de almacenamiento como tipos simples, Map, Vec, structs y enums. Dependiendo de cómo queramos leer o escribir en el almacenamiento, necesitaremos importar los traits apropiados que se enumeran a continuación:
| Trait | Habilita método(s) | Propósito |
|---|---|---|
StoragePointerReadAccess |
.read() |
Leer un valor de una ruta de almacenamiento (tipos simples o campos de struct). |
StoragePointerWriteAccess |
.write(value) |
Escribir un valor en una ruta de almacenamiento (tipos simples o campos de struct). |
StorageMapReadAccess |
.read(key) |
Leer un valor de un Map por clave. |
StorageMapWriteAccess |
.write(key, value) |
Escribir un valor en un Map por clave. |
StoragePathEntry |
.entry(key) |
Navegar más profundamente en el almacenamiento anidado (por ejemplo, Map anidado o storage node). |
VecTrait |
.len(), .get(index), .at(index) |
Acceso de solo lectura a Vec: comprobar longitud, obtener elemento opcionalmente o directamente. |
MutableVecTrait |
.push(value), .pop(), .allocate() |
Mutar un Vec: añadir, eliminar o preparar espacio para un elemento. |
Este artículo es parte de una serie de tutoriales sobre Programación en Cairo en Starknet