Composability and Components in Starknet Cairo

Composability and Components in Starknet Cairo

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 an Event 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 functions

    • self: @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 and Event 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:

  1. 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.

  1. Add the path to the component’s storage and event to be the value of the contract’s Storage variable and Event enum variant.

Adding component’s Storage and Event path to contract’s storage and event

  1. 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)