Los componentes en Cairo se comportan como los contratos abstractos en Solidity. Pueden definir y trabajar con almacenamiento, eventos y funciones, pero no pueden ser desplegados por sí solos. El uso previsto de los componentes es separar la lógica (por ejemplo, para la reutilización) de una manera similar a como lo hacen los contratos abstractos en Solidity.
Considera el siguiente código en Solidity:
abstract contract C {
uint256 balance;
function increase_balance(uint256 amount) public {
require(amount != 0, "amount cannot be zero");
balance = balance + amount;
}
function get_balance() public view returns (uint256) {
return x;
}
}
contract D is C {
}
El contrato C no puede ser desplegado porque es abstracto. Sin embargo, si D es desplegado, entonces D tendrá toda la funcionalidad y el estado de C. Específicamente, D tendrá las funciones públicas increase_balance() y get() que se comportan tal como se definen en C.
D recibió todas las funciones, eventos y el storage de C.
El contrato que construiremos hoy es el equivalente en Cairo del código en Solidity mostrado arriba.
Ejemplo Mínimo de Componente
Crea un directorio vacío y ejecuta scarb init en su interior.
Pega el código de abajo en src/lib.cairo. Ejecuta las pruebas generadas con scarb test; todas deberían pasar.
Esto es lo que hace el código:
- Declara una interfaz con dos funciones que incrementan y devuelven un balance almacenado en el contrato.
- Crea un componente que define su propio almacenamiento
xe implementa las funcionesincreaseyget_balanceusando operaciones de lectura/escritura. - El contrato importa el componente, registra su storage y eventos, y expone la implementación del componente a través de su ABI.
Explicaremos cómo encaja todo esto después del código:
// SAME TRAIT SCARB CREATES BY DEFAULT
#[starknet::interface]
pub trait IHelloStarknet<TContractState> {
/// Increase contract balance.
fn increase_balance(ref self: TContractState, amount: felt252);
/// Retrieve contract balance.
fn get_balance(self: @TContractState) -> felt252;
}
// COMPONENT IS NEW
#[starknet::component]
pub mod CounterComponent {
use starknet::storage::{StoragePointerReadAccess, StoragePointerWriteAccess};
#[storage]
pub struct Storage {
x: felt252,
}
#[event]
#[derive(Drop, Debug, PartialEq, starknet::Event)]
pub enum Event {}
#[embeddable_as(CounterImplMixin)]
impl CounterImpl<TContractState, +HasComponent<TContractState>> of super::IHelloStarknet<ComponentState<TContractState>> {
fn get_balance(self: @ComponentState<TContractState>) -> felt252 {
self.x.read()
}
fn increase_balance(ref self: ComponentState<TContractState>, amount: felt252) {
assert(amount != 0, 'Amount cannot be 0');
self.x.write(self.x.read() + amount);
}
}
}
// THIS CONTRACT HAS NO FUNCTIONALITY, IT ONLY USES THE COMPONENT
#[starknet::contract]
mod HelloStarknet {
use super::CounterComponent;
component!(path: CounterComponent, storage: counter, event: CounterEvent);
#[abi(embed_v0)]
impl CounterImpl = CounterComponent::CounterImplMixin<ContractState>;
#[storage]
struct Storage {
#[substorage(v0)]
counter: CounterComponent::Storage,
}
#[event]
#[derive(Drop, Debug, PartialEq, starknet::Event)]
pub enum Event {
#[flat]
CounterEvent: CounterComponent::Event,
}
}
Desglose del Contrato Anterior
Interfaz IHelloStarknet
El trait en la parte superior del archivo permanece sin cambios respecto al que Scarb crea por defecto.
No lo cambiamos porque los archivos de prueba importan específicamente esta interfaz. Usar un nombre diferente haría que las pruebas no compilaran:
#[starknet::interface]
pub trait IHelloStarknet<TContractState> {
/// Increase contract balance.
fn increase_balance(ref self: TContractState, amount: felt252);
/// Retrieve contract balance.
fn get_balance(self: @TContractState) -> felt252;
}
El Componente Counter
El CounterComponent (similar al “contrato abstracto” en Solidity que vimos antes) es casi idéntico al contrato que Scarb crea por defecto. Las diferencias se explican después de los bloques de código.
CounterComponent:
#[starknet::component]
pub mod CounterComponent {
use starknet::storage::{StoragePointerReadAccess, StoragePointerWriteAccess};
#[storage]
pub struct Storage {
x: felt252,
}
#[event]
#[derive(Drop, Debug, PartialEq, starknet::Event)]
pub enum Event {}
#[embeddable_as(CounterImplMixin)]
impl CounterImpl<TContractState, +HasComponent<TContractState>> of super::IHelloStarknet<ComponentState<TContractState>> {
fn get_balance(self: @ComponentState<TContractState>) -> felt252 {
self.x.read()
}
fn increase_balance(ref self: ComponentState<TContractState>, amount: felt252) {
assert(amount != 0, 'Amount cannot be 0');
self.x.write(self.x.read() + amount);
}
}
}
Contrato por defecto creado por Scarb:
#[starknet::contract]
mod HelloStarknet {
use starknet::storage::{StoragePointerReadAccess, StoragePointerWriteAccess};
#[storage]
struct Storage {
balance: felt252,
}
#[abi(embed_v0)]
impl HelloStarknetImpl of super::IHelloStarknet<ContractState> {
fn increase_balance(ref self: ContractState, amount: felt252) {
assert(amount != 0, 'Amount cannot be 0');
self.balance.write(self.balance.read() + amount);
}
fn get_balance(self: @ContractState) -> felt252 {
self.balance.read()
}
}
}
Aquí están las diferencias entre el CounterComponent y el contrato por defecto que genera Scarb:
- El componente está anotado con el atributo
#[starknet::component]- El contrato está anotado con el atributo
#[starknet::contract]
- El contrato está anotado con el atributo
- El
implen el componente tiene el atributo#[embeddable_as(CounterImplMixin)]- El contrato tiene el atributo
#[abi(embed_v0)]
- El contrato tiene el atributo
- En el componente, el
CounterImpltiene el traitimpl CounterImpl<TContractState, +HasComponent<TContractState>> of super::IHelloStarknet<ComponentState<TContractState>>- El contrato tiene el trait
impl HelloStarknetImpl of super::IHelloStarknet<ContractState>
- El contrato tiene el trait
- El componente declara un bloque de eventos vacío aunque no use eventos
- Los contratos pueden omitir el bloque de eventos, pero un componente no. En la práctica, la mayoría de los componentes del mundo real tendrán eventos. Mantenemos el evento vacío por ahora para maximizar la simplicidad.
A continuación se presenta una explicación detallada de las diferencias enumeradas arriba.
#[starknet::component] vs #[starknet::contract]
Si pretendemos construir un componente en lugar de un contrato, el compilador necesita saber el tipo de módulo. Anotar el bloque mod con #[starknet::component] le indica al compilador que estamos construyendo un componente, mientras que [starknet::contract] le indica al compilador que estamos construyendo un contrato.
#[embeddable_as(CounterImplMixin)]
Este atributo permite que un contrato “incorpore” un impl desde el componente.
// Counter Mixin
#[abi(embed_v0)]
impl CounterImpl = CounterComponent::CounterImplMixin<ContractState>;
En el contrato: CounterComponent se refiere al módulo CounterComponent y CounterImplMixin se refiere al Impl que está incorporando (”mezclando”).
El nombre CounterImplMixin es arbitrario.
Podríamos haber escrito #[embeddable_as(FooBar)] en el componente y poner el siguiente código en el contrato:
#[abi(embed_v0)]
impl CounterImpl = CounterComponent::FooBar<ContractState>;
Podemos definir múltiples implementaciones
embeddable_asdentro de un componente si queremos exponer diferentes bloques impl para diferentes propósitos (mostraremos un ejemplo de esto en un artículo posterior).
Un “Mixin” no es una construcción del lenguaje ni un término que el compilador reconozca. Es terminología idiomática en Cairo para un impl que se incluye en un contrato desde un componente, y ese impl expondrá nuevas funciones “públicas” en el contrato. Un contrato podría incluir un impl que no exponga ninguna función externa, pero esto no se consideraría un “mixin”.
El #[abi(embed_v0)] en el contrato expone las funciones del impl de counter. Si no incluyéramos #[abi(embed_v0)] de la siguiente manera:
// #[abi(embed_v0)] commented out
impl CounterImpl = CounterComponent::Counter<ContractState>;
Nuestro código aún compilaría, pero no habría funciones públicas, por lo que las pruebas no pasarían.
Entendiendo la definición de impl en el Componente
La definición de impl en el componente anterior se ve así:
impl CounterImpl<TContractState, +HasComponent<TContractState>> of super::IHelloStarknet<ComponentState<TContractState>>
Esto parece intimidante al principio, especialmente si no tienes experiencia previa con Rust. La buena noticia es que es principalmente código repetitivo (boilerplate), reutilizarás este patrón en todos los componentes, no tendrás que reescribirlo. Pero debemos saber qué significa.
En un componente, cada impl sigue esta estructura:
impl {ImplName}<TContractState, +HasComponent<TContractState>> of {PathToTrait}::{TraitName}<ComponentState<TContractState>>
Vamos a desglosarlo:
{ImplName}es el nombre que le das al bloque de implementación. Puede ser cualquier cosa que elijas.TContractStaterepresenta el tipo de estado del contrato.+HasComponent<TContractState>le dice al compilador que el contrato que usa este componente incluye su estado.of {PathToTrait}::{TraitName}vincula la implementación con el trait que define la interfaz del componente.ComponentState<TContractState>significa que el trait opera en la parte del componente del estado del contrato.
En nuestro ejemplo:
{PathToTrait}essuperporque el trait está declarado en el mismo archivo.{TraitName}esIHelloStarknet, ya que las pruebas esperan este nombre específico de trait.
Una vez que entiendas este patrón, puedes reutilizarlo siempre que declares una implementación para un componente.
Cómo el contrato usa el componente
Aquí está el código del contrato nuevamente:
#[starknet::contract]
mod HelloStarknet {
use super::CounterComponent;
component!(path: CounterComponent, storage: counter, event: CounterEvent);
#[abi(embed_v0)]
impl CounterImpl = CounterComponent::CounterImplMixin<ContractState>;
#[storage]
struct Storage {
#[substorage(v0)]
counter: CounterComponent::Storage,
}
#[event]
#[derive(Drop, Debug, PartialEq, starknet::Event)]
pub enum Event {
#[flat]
CounterEvent: CounterComponent::Event,
}
}
En Solidity, las funciones, las variables de storage y los eventos son automáticamente “importados” cuando un contrato hereda de un contrato abstracto. Este no es el caso en Cairo.
Para “incorporar” un componente, necesitamos seguir esta lista de verificación:
- Importar el componente con
use. En este caso, esuse super::CounterComponent. Esto simplemente hace que el código esté disponible y no lo integra en el componente - Debemos declarar la macro
component!. Esto se explicará con más detalle en breve - Las funciones públicas deben ser mezcladas (mixed in)
- El storage debe ser incrustado como
#[substorage(v0)] - Los eventos deben ser incrustados con
#[flat]
Ninguno de estos pasos es opcional. A continuación se presenta una explicación detallada de cada elemento de la lista de verificación.
Importando el Componente
Nombrar nuestro CounterComponent ”CounterComponent” es opcional. Podría llamarse “SparklingWaterIsTasty” y estaría bien. Sin embargo, el nombre del componente que importamos debe ser el mismo nombre utilizado para:
pathencomponent!- Debe ser la fuente del Mixin, Storage y Event tal como se resalta a continuación

Importando el impl
Para incluir las funciones externas del componente en nuestro contrato, debemos hacer lo siguiente:
- Declarar un
imply hacerlo externo con el atributo#[abi(embed_v0)](en naranja abajo) - Incorporar el
CounterImplMixindesde el componente. El nombreCounterImplMixindebe coincidir con el nombre declarado en el#[embeddable_as(CounterImplMixin)]del componente. Hacer coincidir el nombre del impl en el componente no garantiza que la importación funcione, debes usar el nombre declarado en la macroembeddable_as. - Finalmente, le damos al mixin
CounterImplMixin“acceso” al storage del contrato “pasando” elContractState(como se muestra en el recuadro blanco abajo).

Importando el storage
A diferencia de la herencia de contratos que importa automáticamente el storage, esto debe hacerse manualmente en Cairo.
Todo el storage de un contrato existe en el struct etiquetado con #[storage] en el contrato. Aunque también hay un struct #[storage] en el componente, este no “cuenta” ya que está en un componente.
Afortunadamente, no tenemos que importar cada variable de storage por separado. Importamos el storage “de una sola vez” con el atributo #[substorage(v0)].
Ahora mostremos cómo importar el storage:
- Todo el storage importado de un componente debe tener una clave dentro del struct storage del contrato. El nombre de esta clave debe coincidir con el nombre declarado en la macro
component!(el recuadro verde y la flecha abajo). Podría llamarse de cualquier manera, pero deben ser consistentes entre el valor declarado encomponent!y la clave en el struct. El nombrecounteren sí es arbitrario. Este enlace también es la forma en que el compilador sabe que el storage dentro decounterfue definido en otro lugar. - Para incorporar el struct de storage del componente (no del contrato), lo ponemos como el valor en el struct como
CounterComponent::Storage(Recuadro amarillo y morado abajo). Nota queStorageaquí es el nombre del struct en el componente.

Importando los eventos
Importar los eventos sigue el mismo patrón que importar el storage:
- El
CounterEventdeclarado en la macrocomponent!debe coincidir con el elemento correspondiente en el enumEventdel contrato. Esta coincidencia uno a uno es como el compilador sabe que el evento está definido fuera del contrato. El nombreCounterEventes arbitrario, pero cualquier nombre que elijamos debe aparecer exactamente igual tanto en la macrocomponent!como en la variante del enum. - El atributo
#[flat](como se muestra en el recuadro naranja abajo) encima de la entrada es un código repetitivo (boilerplate) requerido que le dice al compilador que aplane (flatten) los eventos del componente dentro de la estructura de eventos de tu contrato en lugar de anidarlos. - El
CounterComponentes cómo incorporamos elEvent. ElEventen magenta es el enumEventdeclarado en el componente.

Resumen
Un componente crea sus propias funciones, storage y eventos, pero no puede ser desplegado como un contrato.
Un componente puede ser importado a un contrato usando una importación y declarando referencias con component!
Las funciones, el storage y los eventos deben ser importados por separado.
Para importar las funciones, crea un nuevo impl declarado con #[abi(embed_v0)] y establece la implementación al nombre del mixin especificado en #[embeddable_as(mixin_name)].
Para importar el storage, crea una nueva clave en el storage del contrato con #[substorage(v0)]. Establece la clave para el storage con el mismo nombre declarado para storage: en la macro component!. Luego establece el valor como la ruta hacia el struct de storage en el componente.
Para importar los eventos, crea una nueva entrada en el enum de eventos y aplícale el atributo #[flat]. Establece la entrada con el mismo nombre declarado para event: en la macro component!. Luego establece el tipo como la ruta hacia el enum en el componente.
Este artículo es parte de una serie de tutoriales sobre Cairo Programming on Starknet