While learning how to write Starknet contracts using Cairo, I came across the concept of components, and I was fascinated by how it works under the hood. I can’t help but appreciate the amazing engineers behind this and knowing fully well that concepts like this can be a pain in the ass to understand, let me exercise my detailed explanation prowess in breaking things down.
Prerequisites
To get the best out of this article you should already:
Have your Cairo dev environment completely setup (i.e you have installed Scarb, Starkli, Sn-Foundry and VS-Code).
Understand the Cairo syntax and data types.
Know how to write basic Starknet smart contracts using Cairo.
Composability and components are important concepts in Cairo (Cairo 1), a programming language designed for developing smart contracts on the Starknet blockchain.
Composability: In Cairo, this refers to the ability to combine different pieces of code or smart contract to create more complex and feature-rich applications. It enables developers to reuse existing code and smart contracts, build on top of other contracts, and create modular and interoperable systems.
Components: Components in Cairo are reusable pieces of code that encapsulate specific functionalities. The equivalent of components in Solidity are abstract contracts, although they have slight differences in how they are being used, but they share a similar concept of reusable codes that cannot be directly instantiated and are meant to be extended or used by other contracts.
Like contracts in Cairo, components can contain storage, events and functions, but they cannot be declared or deployed. They can only be injected into any smart contract, and they eventually become part of the contract’s bytecode they are embedded to.
Embeddable Impls : The building rock of Components
While components provide a powerful modularity to smart contracts on Starknet, it is dependent on some underground concepts, one of which is the embeddable impls.
Embeddable impl (embeddable implementation) is an implementation of an interface that can be embedded to any other smart contract.
Let’s break it down: let’s say you have an interface (i.e a trait that is marked with #[starknet::interface]
), then you created an impl
for the interface, to make the impl embeddable, you have to mark it with #[starknet::embeddable]
attribute. Doing this, the impl block you created can be embedded in other smart contracts and the functions in the embedded impl block will be available in any smart contract it is embedded in and it will also be available in the contract’s ABI it is injected to.
Let’s use the following code snippet to show how embeddable impl works:
#[starknet::interface]
trait SimpleTrait<TContractState> {
fn return_num(self: @TContractState) -> u8;
}
#[starknet::embeddable]
impl SimpleImpl<TContractState> of SimpleTrait<TContractState> {
fn return_num(self: @TContractState) -> u8 {
4
}
}
#[starknet::contract]
mod simple_contract {
#[storage]
struct Storage {}
#[abi(embed_v0)]
impl MySimpleImpl = super::SimpleImpl<ContractState>;
}
From the image above, the section in a red box is an interface, which is a trait marked with #[starknet::interface]
attribute. It contains one function signature.
The section in the green box is the implementation of the interface. The impl SimpleImpl
is marked with #[starknet::embeddable]
which makes it an embeddable impl that can be embedded to other smart contracts.
Lastly, the section in the yellow box is a Starknet smart contract where the SimpleImple (embeddable impl) is embedded. Line 19 is where SimpleImpl
was embedded to simple_contract
, using an impl alias syntax.
Observe that the line where SimpleImpl
was embedded in the simple_contract
is marked by #[abi(embed_v0)]
above it, which simply makes the function in SimpleImpl
(the embedded impl) an entry point into the contract in which it is embedded to.
Entry point here means it is visible to the public and can be called externally.
By embedding SimpleImpl
to simple_contract
, we externally expose the return_num
function in the simple_contract
ABI.
Creating Components
Now that we have explained the embedding mechanism, we are halfway to understanding how components work, we need to start by creating a component, then we can break down the logic involved as we proceed.
Creating components involves the following steps:
Define the component in its own module decorated with a
#[starknet::component]
attribute.Within this module, declare a
Storage
struct and anEvent
enum, same way you do in your smart contracts.Define an interface for the component that contains function signatures of the functions that will give access to the component’s logic. Interface definition is similar to how it is done in smart contracts using trait that is marked with
#[starknet::interface]
Inside the component module create an
impl
block that is marked as#[embeddable_as(name)]
.name
is any name you want your component to be referred to when being used in contracts.To define internal functions that won’t have external access, create another
impl
block without using#[embeddable_as(name)]
. These internal functions can be used inside the contract where the component is attached to but cannot be called externally.Functions within the
impl
block expects arguments like:ref self: ComponentState<TContractState>
: for state-modifying functionsself: @ComponentState<TContractState>
: for view functions
This makes the impl
generic over TContractState
, allowing the component to be used in any smart contract.
This is similar to the SimpleImpl
embeddable impl which can also be embedded in any contract.
Enough of the talk, let’s show some codes.
I am using the command scarb init –name creating_components
to create a project. You should already know how to do this as a Cairo developer. See my project structure below:
Paste the following code inside ownable_component.cairo
use core::starknet::ContractAddress;
#[starknet::interface]
trait IOwnable<TContractState> {
fn owner(self: @TContractState) -> ContractAddress;
fn transfer_ownership(ref self: TContractState, new_owner: ContractAddress);
fn renounce_ownership(ref self: TContractState);
fn increase_count(ref self: TContractState);
}
#[starknet::component]
pub mod OwnableComponent {
use core::starknet::{ContractAddress, get_caller_address};
use core::starknet::storage::{StoragePointerReadAccess, StoragePointerWriteAccess};
use core::num::traits::Zero;
#[storage]
struct Storage {
owner: ContractAddress,
count: u64,
}
#[event]
#[derive(Drop, starknet::Event)]
pub enum Event {
OwnershipTransferred: OwnershipTransferred,
}
#[derive(Drop, starknet::Event)]
pub struct OwnershipTransferred {
previous_owner: ContractAddress,
new_owner: ContractAddress,
}
mod Errors {
pub const NOT_OWNER: felt252 = 'Caller is not the owner';
pub const ZERO_ADDRESS_CALLER: felt252 = 'Caller is the zero address';
pub const ZERO_ADDRESS_OWNER: felt252 = 'New owner is the zero address';
}
#[embeddable_as(Ownable)]
impl OwnableImpl<TContractState, +HasComponent<TContractState>> of super::IOwnable<ComponentState<TContractState>> {
fn owner(self: @ComponentState<TContractState>) -> ContractAddress {
self.owner.read()
}
fn transfer_ownership(ref self: ComponentState<TContractState>, new_owner: ContractAddress) {
assert(!new_owner.is_zero(), Errors::ZERO_ADDRESS_OWNER);
self.assert_only_owner();
self._transfer_ownership(new_owner);
}
fn renounce_ownership(ref self: ComponentState<TContractState>) {
self.assert_only_owner();
self._transfer_ownership(Zero::zero());
}
fn increase_count(ref self: ComponentState<TContractState>) {
let prev_count = self.count.read();
self.count.write(prev_count + 1);
}
}
#[generate_trait]
pub impl InternalImpl<TContractState, +HasComponent<TContractState>> of InternalTrait<TContractState> {
fn initializer(ref self: ComponentState<TContractState>, owner: ContractAddress) {
self._transfer_ownership(owner);
}
fn assert_only_owner(self: @ComponentState<TContractState>) {
let owner: ContractAddress = self.owner.read();
let caller: ContractAddress = get_caller_address();
assert(!caller.is_zero(), Errors::ZERO_ADDRESS_CALLER);
assert(caller == owner, Errors::NOT_OWNER);
}
fn _transfer_ownership(ref self: ComponentState<TContractState>, new_owner: ContractAddress) {
let previous_owner: ContractAddress = self.owner.read();
self.owner.write(new_owner);
self.emit(OwnershipTransferred { previous_owner: previous_owner, new_owner: new_owner });
}
}
}
From the code above, we will go through explaining it in sections using screenshots.
Although the codes in a component look much similar to contract codes, there are differences and areas that need more attention and detailed explanation.
Global import & Component’s Interface
Line 1 is an import of ContractAddress
type. Line 3 - 9 is the component interface that contains function signatures of the functions that will be given external access in any contract that uses the component.
Component’s module, storage, event & impl
Line 12 is the definition of the component module that is decorated with a #[starkent::component]
attribute on line 11.
Line 18 - 22 is the Storage
struct that is the component’s storage.
Line 24 - 28 is an Event
enum which is the component’s event.
Line 36 - 40 is a module Errors
holding constants of error messages to be used in the impl
block.
The highlight of a component is the impl
blocks. On line 44, notice that it is very different from the way impl
blocks are defined in smart contracts. This is where the awesomeness begins.
impl OwnableImpl<TContractState, +HasComponent<TContractState>>
Line 44 is defining an impl
block that passes a generic TContractState
with a trait bound +HasComponent<TContractState>
.
The TContractState
is a generic type, passed into the OwnableImpl
. This can be any type since it is a generic, but the additional trait bound +HasComponent<TContractState>
species that the generic type TContractState
must be a type that implements the HasComponent
trait.
I know this might sound like a lot, let’s explain it in simple terms: *A trait bound is like a requirement that a “type” must meet before it can be used in a certain way.
Imagine you have a function that needs to work with different type of objects, in our case the object is TContractState
. But you don’t want it to work with just any object - you want it to work with objects that can do certain things (things like HasComponent
). This is where trait bounds are used.
On line 44, the +HasComponent<TContractState>
is saying: “This implementation will work with any TContractState
, but only if the TContractState
has the capabilities defined in the HasComponent
trait”.
It’s more like saying “I can work with any kind of cake (TContractState), but only if the cake has icing (HasComponent)”.
super::IOwnable<ComponentState<TContractState>>
Still on line 44, let’s look at the other part which is super::IOwnable<ComponentState<TContractState>>
. super
is a way of bringing the IOwnable
interface in scope since it was declared outside the component’s module. ComponentState<TContractState>
is a type that is being passed as a generic parameter to IOwnable
TContractState
is a generic type parameter representing the state of a contract.
ComponentState<TContractState>
implies that ComponentState
is a wrapper of TContractState
.
As we proceed you will understand why HasComponent
is used as a trait bound for TContractState
.
With the impl
using TContractState
as generic, ensuring that the actual ContractState
implements HasComponent<T>
trait, is what allows us to use the component in any contract as long as the contract implements the HasComponent
trait.
Component’s internal function impl
Line 68 - 91 is an impl
block that contains internal functions which will not be exposed to be accessed externally in the contract that uses the component but can be accessed within the contract.
Using Components inside a Contract
Creating components is one side of the coin especially when understanding components is the goal. Using components in a contract will help you understand some of the codes written during component creation.
Components strength lies in its reusability in smart contracts. Integrating it into your smart contract requires the following steps:
Declare your component with the
component!()
macro, and it the macro, specify the following:The path to the component
path::to::component
(This is the part of which the component is imported into the smart contract).The name of your contract’s storage variable that is referring to the component’s storage. (To have access to a components storage, you need a variable in the contract’s storage that points to the component’s storage).
The name of the contract’s
Event
enum variant refers to the component’s events. (Similarly, to have access to a component’s event, you to create an event variant in your contract that point to the component’s event).
Add the path to the component’s storage and event to be the value of the contract’s
Storage
variable andEvent
enum variant.The storage must be annotated with the
#[substorage(v0)]
attribute.Embed the component’s logic in your contract using the impl alias syntax same way we did in the embeddable impl. When doing this, instantiate the component’s generic with a concrete
ContractState
. Make sure this alias is annotated with#[abi(embed_v0)]
to externally expose the component’s functions.
That was a lot of steps, it can be confusing when too many jargons are involved, let’s see the code to understand things better.
Remember my project directory structure? See it below:
We have worked with the ownable_component.cairo file. Let’s fill up the other files with code.
Inside the lib.cairo, copy and paste in the following code:
mod ownable_component;
mod ownable_counter;
In the ownable_counter.cairo, copy and paste the following code:
#[starknet::contract]
mod OwnableCounter {
use creating_components::ownable_component::OwnableComponent;
use core::starknet::storage::{StoragePointerReadAccess, StoragePointerWriteAccess};
component!(path: OwnableComponent, storage: ownable, event: OwnableEvent);
#[abi(embed_v0)]
impl OwnableImpl = OwnableComponent::Ownable<ContractState>;
impl OwnableInternalImpl = OwnableComponent::InternalImpl<ContractState>;
#[storage]
struct Storage {
counter: u128,
#[substorage(v0)]
ownable: OwnableComponent::Storage
}
#[event]
#[derive(Drop, starknet::Event)]
enum Event {
OwnableEvent: OwnableComponent::Event
}
#[abi(embed_v0)]
fn foo(ref self: ContractState) {
self.ownable.assert_only_owner();
self.counter.write(self.counter.read() + 1);
self.ownable.increase_count();
}
}
Here comes the interesting part. Let’s break down what’s happening in the code snippet above in a bid to achieve complete (100%) understanding of components and how they work. We will be using code screenshots for the explanation.
Referring to the steps of using a component:
- Declare your component with the
component!()
macro, and it the macro, specify the following:
The path to the component
path::to::component
(This is the part of which the component is imported into the smart contract)The name of your contract’s storage variable that is referring to the component’s storage. (To have access to a components storage, you need a variable in the contract’s storage that points to the component’s storage)
The name of the contract’s
Event
enum variant refers to the component’s events. (Similarly, to have access to a component’s event, you to create an event variant in your contract that point to the component’s event)
component!() macro declaration
Line 3 is the contract module annotated with #[starknet::contract]
on line 2. Line 4 is where we are importing the component into the OwnableCounter
module. This is also the path::to::component
.
Line 7 is where we are using the component!()
macro, and passing the 3 arguments it needs as specified above.
OwnableComponent
is the name our component module, storage: ownable
specifies the storage variable in our contract where we want to point the component’s storage to, while event: OwnableEvent
points to the Event
enum variant that will hold the component’s event.
component!() macro
When the component!()
is used in the contract, the Cairo compiler automatically generates an implementation of the HasComponent
trait for the contract in which component!()
macro was used.
Now component!()
macro is used, the ContractState of the contract now implements the HasComponent
trait which is the trait bound that was added in the component’s implementation.
What then is the importance of this HasComponent
trait? Why is it needed in the implementation of components? Worry not, you will know why shortly.
- Add the path to the component’s storage and event to be the value of the contract’s
Storage
variable andEvent
enum variant.
Adding component’s Storage and Event path to contract’s storage and event
- Embed the component’s logic in your contract using the impl alias syntax same way we did in the embeddable impl. When doing this, instantiate the component’s generic with a concrete
ContractState
. Make sure this alias is annotated with#[abi(embed_v0)]
to externally expose the component’s functions.
Embedding component’s logic in the contract
In the above image the highlighted section is where we are embedding the component’s logic into the smart contract using the impl alias syntax. We also annotated the impl
with #[abi(embed_v0)]
making the functions from the component accessible externally via our contract.
Embedding Internal function logic in the contract
Why is HasComponent trait important?
Let’s answer the question: What then is the importance of this HasComponent
trait? Why is it needed in the implementation of components?
Recall that in the impl
block of the component, the self
parameter of the function is a generic type TContractState
that’s wrapped with ComponentState
see image below:
Notice that instead of using TContractState
as the generic type for self
, ComponentState<TContractState>
was used instead.
This is where the HasComponent
trait becomes very useful. Read the following paragraphs with more attention.
OwnableImpl
in the component requires the implementation of the HasComponent<TContractState>
trait by the underlying contract, which is automatically generated when we use the component!()
macro in a contract. When this is done, the compiler generates an impl that wraps any function in OwnableImpl
, replacing the self: ComponentState<TContractState>
argument with self: TContractState
where access to the component state is made via the get_component
function in the HasComponent<TContractState>
trait.
The HasComponent
trait defines an interface to bridge between the actual TContractState
of a generic contract, and ComponentState<TContractState>
. This means that, the HasComponent
trait is responsible for switching between TContractState
and ComponenState<TContractState>
.
The following function signatures makes up the HasComponent
trait:
// generated per component
trait HasComponent<TContractState> {
fn get_component(self: @TContractState) -> @ComponentState<TContractState>;
fn get_component_mut(ref self: TContractState) -> ComponentState<TContractState>;
fn get_contract(self: @ComponentState<TContractState>) -> @TContractState;
fn get_contract_mut(ref self: ComponentState<TContractState>) -> TContractState;
fn emit<S, impl IntoImp: traits::Into<S, Event>>(ref self: ComponentState<TContractState>, event: S);
}
Conclusion
Okay! honestly, I don’t know what to write here, but to not keep this part empty, I believe the best way to understand components in Cairo is to use components more often. Convert some of your contracts to reusable components and play around with it.
Everything I have said in this piece is in the Cairo book. Read from here to here, don’t rush, take your time, and you will know your way around creating and using components.
CHEERS.
References
Composability and Components - The Cairo Programming Language (cairo-lang.org)