Cairo Components

Components

Intro

Components are a way to achieve extensibility - enriching my contract’s functionality (storage, events, and external functions) with a module that a third party wrote. Let’s consider the following simple contract maintaining a u128 counter:


#[starknet::interface]
trait ICounterContract<TContractState> {
    fn increase_counter(ref self: TContractState, amount: u128);
    fn decrease_counter(ref self: TContractState, amount: u128);
    fn get_counter(self: @TContractState) -> u128;
}

#[starknet::contract]
mod counter_contract {

    #[storage]
    struct Storage {
        counter: u128,
    }

    #[event]
    #[derive(Drop, starknet::Event)]
    enum Event {
        CounterIncreased: CounterIncreased,
        CounterDecreased: CounterDecreased
    }

    #[derive(Drop, starknet::Event)]
    struct CounterIncreased {
        amount: u128
    }

    #[derive(Drop, starknet::Event)]
    struct CounterDecreased {
        amount: u128
    }

    #[constructor]
    fn constructor(ref self: ContractState, initial_counter: u128) {
        self.counter.write(initial_counter);
    }

    #[external(v0)]
    impl CounterContract of super::ICounterContract<ContractState> {
        fn get_counter(self: @ContractState) -> u128 {
            self.counter.read()
        }

        fn increase_counter(ref self: ContractState, amount: u128) {
            let current = self.counter.read();
            self.counter.write(current + amount);
            self.emit(CounterIncreased { amount });
        }

        fn decrease_counter(ref self: ContractState, amount: u128) {
            let current = self.counter.read();
            self.counter.write(current - amount);
            self.emit(CounterDecreased { amount });
        }
    }
}

Suppose that we now want to be able to upgrade this contract. Ideally, we won’t have to write the upgradability-related logic ourselves. That’s where components come in. A component is a separate module that can contain storage, events, and external functions. Unlike a contract, a component cannot be declared or deployed. Its logic will eventually be part of your contract’s bytecode.

Writing a Component

Now that we know what to expect, let’s examine the following (simplified) upgradability component:

// upgradable.cairo

#[starknet::interface]
trait IUpgradable<TContractState> {
    fn upgrade(ref self: TContractState, new_class_hash: ClassHash);
}

#[starknet::component]
mod upgradable {
    use starknet::ClassHash;
    use starknet::syscalls::replace_class_syscall;

    #[storage]
    struct Storage {
        current_implementation: ClassHash
    }

    #[event]
    #[derive(Drop, starknet::Event)]
    enum Event {
        ContractUpgraded: ContractUpgraded
    }

    #[derive(Drop, starknet::Event)]
    struct ContractUpgraded {
        old_class_hash: ClassHash,
        new_class_hash: ClassHash
    }

    #[embeddable_as(UpgradableImpl)]
    impl Upgradable<
        TContractState, +HasComponent<TContractState>
    > of super::IUpgradable<ComponentState<TContractState>> {
        fn upgrade(ref self: ComponentState<TContractState>, new_class_hash: ClassHash) {
            replace_class_syscall(new_class_hash).unwrap();
            let old_class_hash = self.current_implementation.read();
            self.emit(ContractUpgraded { old_class_hash, new_class_hash });
            self.current_implementation.write(new_class_hash);
        }
    }
}

Let’s examine what’s special in the above code:

The component module

The component lives in its own module, annotated with the #[starknet::component] attribute. Inside the component, the general structure is very similar to that of a regular contract. We see the Storage struct and the Event enum, followed by the component’s logic.

ComponentState vs. ContractState

One of the major differences from a regular smart contract is that access to storage and events is done via the generic ComponentState<TContractState> type and not ContractState. Note that while the type is different, accessing storage or emitting events is done similarly via self.storage_var_name.read() or self.emit(...).

Exposing the component’s logic

To expose the component’s logic, in a way that will add external functions to the using contract, we need to implement a #[starknet::interface] trait and mark the impl with [embeddable_as(<name>)], <name> is the name that we’ll be using in the contract. The functions in the impl expect the argument ref self: ComponentState<TContractState> (for external functions) or self: @ComponentState<TContractState> (for view functions), which makes the impl generic over TContractState. To see why we need a dependency over the HasComponent trait, see the appendix.

Using a component in a contract

We will now see how to use the upgradability component in our counter contract.

For completeness, this is our project hierarchy, which we’re building by running scarb build:

comp_examples
	src
		lib.cairo
		upgradable.cairo
		counter_contract.cairo
	scarb.toml
// counter_contract.cairo

#[starknet::interface]
trait ICounterContract<TContractState> {
    fn increase_counter(ref self: TContractState, amount: u128);
    fn decrease_counter(ref self: TContractState, amount: u128);
    fn get_counter(self: @TContractState) -> u128;
}

#[starknet::contract]
mod counter_contract {
	  use comp_examples::upgradable::upgradable as upgradable_component;

    component!(path: upgradable_component, storage: upgradable, event: UpgradableEvent);

		#[abi(embed_v0)]
    impl Upgradable = upgradable_component::UpgradableImpl<ContractState>;

    #[storage]
    struct Storage {
        counter: u128,
        #[substorage(v0)]
        upgradable: upgradable_component::Storage
    }

    #[event]
    #[derive(Drop, starknet::Event)]
    enum Event {
        CounterIncreased: CounterIncreased,
        CounterDecreased: CounterDecreased,
        UpgradableEvent: upgradable_component::Event
    }

    #[derive(Drop, starknet::Event)]
    struct CounterIncreased {
        amount: u128
    }

    #[derive(Drop, starknet::Event)]
    struct CounterDecreased {
        amount: u128
    }

    #[constructor]
    fn constructor(ref self: ContractState, initial_counter: u128) {
        self.counter.write(initial_counter);
    }

    #[external(v0)]
    impl CounterContract of super::ICounterContract<ContractState> {
        fn get_counter(self: @ContractState) -> u128 {
            self.counter.read()
        }

        fn increase_counter(ref self: ContractState, amount: u128) {
            let current = self.counter.read();
            self.counter.write(current + amount);
            self.emit(CounterIncreased { amount });
        }

        fn decrease_counter(ref self: ContractState, amount: u128) {
            let current = self.counter.read();
            self.counter.write(current - amount);
            self.emit(CounterDecreased { amount });
        }
    }
}

We can summarize the changes to the contract in three steps:

  • Declaring the component with:
component!(path: upgradable_component, storage: upgradable, event: UpgradableEvent);

This tells the compiler to generate an implementation for HasComponent<TContractState>, constructing the component state from the associated storage and event types.

  • Add the component’s storage and events to the Storage and Event types:
// Add to Storage	
#[substorage(v0)]
upgradable: upgradable_component::Storage
// Add to Event
UpgradableEvent: upgradable_component::Event

Note that if the component does not emit any events, the compiler generates an empty Event enum inside the component module, so the above line still applies.

  • embed UpgradableImpl:
#[abi(embed_v0)]
impl Upgradable = upgradable_component::UpgradableImpl<ContractSate>;

These lines instantiate UpgradableImpl from the upgradable component with the concrete ContractState type, and externalizes (each function in the impl is now accessible externally, and the impl/interface are reflected in the ABI). For more details on how exactly this works, see the appendix.

Component dependencies

Suppose now that we want to add ownership to the counter contract, in a way that only allows the owner to initiate an upgrade. One way to do it is adding an owner to the storage of Upgradable, resulting in a new component that provides both upgradability and ownership. However, this solution will not allow sharing this owner between different usecases. Suppose that I want to define an owner that controls both upgradability and increasing/decreasing the counter.

Below we only present a single example of dependencies, to get a better idea of the potential composition you can do, you can look at two additional examples in this repository.

Adding a dependency on OwnableTrait to the Upgradable component

#[starknet::interface]
trait IUpgradable<TContractState> {
    fn upgrade(ref self: TContractState, new_class_hash: ClassHash);
}

trait OwnableTrait<TContractState> {
    fn is_owner(self: @TContractState, address: ContractAddress) -> bool;
}

#[starknet::component]
mod upgradable {
    use starknet::{ClassHash, get_caller_address};
    use starknet::syscalls::replace_class_syscall;

    #[storage]
    struct Storage {
        current_implementation: ClassHash
    }

    #[event]
    #[derive(Drop, starknet::Event)]
    enum Event {
        ContractUpgraded: ContractUpgraded
    }

    #[derive(Drop, starknet::Event)]
    struct ContractUpgraded {
        old_class_hash: ClassHash,
        new_class_hash: ClassHash
    }

    #[embeddable_as(UpgradableImpl)]
    impl Upgradable<
        TContractState, +HasComponent<TContractState>, +OwnableTrait<TContractstate>
    > of super::IUpgradable<ComponentState<TContractState>> {
	        fn upgrade(ref self: ComponentState<TContractState>, new_class_hash: ClassHash) {
			let is_owner = self.get_contract().is_owner(get_caller_address());
			if is_owner {
				replace_class_syscall(new_class_hash).unwrap();
				let old_class_hash = self.current_implementation.read();
				self.emit(ContractUpgraded { old_class_hash, new_class_hash });
				self.current_implementation.write(new_class_hash);
			}
		}
	}
}

Recall that the HasComponent (see the appendix) trait allows us to move from ComponentState<TContractState> back to the generic TContractState (which we consider the state of the contract using the component) via the get_contract function. By “moving up” to the using contract, it is sufficient to add a generic impl dependency +OwnableTrait<TContractstate>. Note that we gain dependencies for free from the standard generic impl mechanism.

Adding this dependency made Upgradable pluggable only into contracts that implement the OwnableTrait trait. They can do it via implementing it directly or by using an Ownable component, from the perspective of the Upgradable component - this is an implementation detail.

Implementing OwnableTrait in the contract using the component

In the code below, we chose to implement OwnableTrait inside our counter contract directly (rather than via a second component).

#[starknet::interface]
trait ICounterContract<TContractState> {
    fn increase_counter(ref self: TContractState, amount: u128);
    fn decrease_counter(ref self: TContractState, amount: u128);
    fn get_counter(self: @TContractState) -> u128;
}

#[starknet::contract]
mod counter_contract {
	  use comp_examples::upgradable::upgradable as upgradable_component;

    component!(path: upgradable_component, storage: upgradable, event: UpgradableEvent);

    #[abi(embed_v0)]
    impl Upgradable = upgradable_component::UpgradableImpl<ContractState>;

    impl Ownable of super::OwnableTrait<ContractState> {
        fn is_owner(self: @ContractState, address: ContractAddress) -> bool {
            let caller = get_caller_address();
            let owner = self.owner_address.read();
            caller == owner
        }
    }

    #[storage]
    struct Storage {
        counter: u128,
        #[substorage(v0)]
        upgradable: upgradable_component::Storage
    }

    #[event]
    #[derive(Drop, starknet::Event)]
    enum Event {
        CounterIncreased: CounterIncreased,
        CounterDecreased: CounterDecreased,
        UpgradableEvent: upgradable_component::Event
    }

    #[derive(Drop, starknet::Event)]
    struct CounterIncreased {
        amount: u128
    }

    #[derive(Drop, starknet::Event)]
    struct CounterDecreased {
        amount: u128
    }

    #[constructor]
    fn constructor(ref self: ContractState, initial_counter: u128) {
        self.counter.write(initial_counter);
    }

    #[external(v0)]
    impl CounterContract of super::ICounterContract<ContractState> {
        fn get_counter(self: @ContractState) -> u128 {
            self.counter.read()
        }

        fn increase_counter(ref self: ContractState, amount: u128) {
            let is_owner = self.is_owner(get_caller_address());
            if is_owner {
                let current = self.counter.read();
                self.counter.write(current + amount);
                self.emit(CounterIncreased { amount });
            }
        }

        fn decrease_counter(ref self: ContractState, amount: u128) {
            let is_owner = self.is_owner(get_caller_address());
            if is_owner {
                let current = self.counter.read();
                self.counter.write(current - amount);
                self.emit(CounterDecreased { amount });
            }
        }
    }
}

Note that if we remove the Ownable impl, then the following line will not compile:

    impl Upgradable = upgradable_component::UpgradableImpl<ContractState>;

since we cannot instantiate the impl UpgradableImpl<ContractState> if there is no present impl for OwnableTrait<ContractState> (and in particular, we won’t be able to embed this impl in our contract).

Appendix - exposing the component logic, what happens under the hood

The purpose of this section is to explain what exactly is going on in the compiler in the following lines:

#[embeddable_as(UpgradableImpl)]
impl Upgradable<
    TContractState, +HasComponent<TContractState>
> of super::IUpgradable<ComponentState<TContractState>> {
    fn upgrade(ref self: ComponentState<TContractState>, new_class_hash: ClassHash) {
			...
    }
}

If you only want to start playing with components, you can skip this part. If, however, you want to understand the exact role of every single line, we suggest that you continue reading.

Before we zoom into the Upgradable impl, we need to discuss embeddable impls, a new feature introduced in Cairo v2.3.0. An impl of a starknet interface trait (that is, a trait annotated with the #[starknet::interface] attribute) can be embeddable. One can embed embeddable impls in any contract, consequently adding new entry points and changing the ABI. Let’s consider the following example (which is not using components):

#[starknet::interface]
trait SimpleTrait<TContractState> {
    fn ret_4(self: @TContractState) -> u8;
}

#[starknet::embeddable]
impl SimpleImpl<TContractState> of SimpleTrait<TContractState> {
    fn ret_4(self: @TContractState) -> u8 {
        4
    }
}

#[starknet::contract]
mod simple_contract {
    #[storage]
    struct Storage {}

    #[abi(embed_v0)]
    impl MySimpleImpl = super::SimpleImpl<ContractState>;
}

The ABI of the above simple contract is:

{
	"abi": [
	    {
	      "type": "impl",
	      "name": "SimpleImpl",
	      "interface_name": "simple_contract::simple_contract::SimpleTrait"
	    },
	    {
	      "type": "interface",
	      "name": "simple_contract::simple_contract::SimpleTrait",
	      "items": [
	        {
	          "type": "function",
	          "name": "ret_4",
	          "inputs": [],
	          "outputs": [
	            {
	              "type": "core::u8"
	            }
	          ],
	          "state_mutability": "view"
	        }
	      ]
	    },
	    {
	      "type": "event",
	      "name": "simple_contract::simple_contract::simple_contract::Event",
	      "kind": "enum",
	      "variants": []
	    }
	  ]
}

As we see, by embedding the impl with the following impl alias syntax (also introduced in Cairo v2.3.0:

#[abi(embed_v0)]
impl MySimpleImpl = super::SimpleImpl<ContractState>;

we have added MySimpleImpl and SimpleTrait to the ABI of the contract, and can call ret4 externally.

Now that we’re more familiar with the embedding mechanism, we can go back to the Upgradable impl inside our component:

#[embeddable_as(UpgradableImpl)]
	impl Upgradable<TContractState, +HasComponent<TContractState>> of super:IUpgradable<ComponentState<TContractState>> {
	...
}

Zooming in, we can notice two component-specific changes:

  • Upgradable is dependent on an implementation of the HasComponent<TContractState> trait

    This dependency allows the compiler to generate an impl that can be used on every contract. That is, the compiler will generate an impl that wraps any function in Upgradeable, replacing the self: ComponentState<TContractState> argument with self: TContractState, where access to the component state is made via get_component function in the HasComponent<TContractState> trait.

    To get a more complete picture of what’s being generated behind the scenes, the following trait is being generated by the compiler per a component module:

    // 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);
    } 
    

    In our context ComponentState<TContractState> is a type specific to the upgradable component, i.e. it has members based on the storage variables defined in upgradable_component::Storage. Moving from the generic TContractState to ComponentState<TContractState> will allow us to embed Upgradable in any contract that wants to use it. The opposite direction (ComponentState<TContractState> to ContractState) is useful for dependencies (see the Upgradable component depending on an OwnableTrait implementation example)

    To put it briefly, one should think of an implementation of the above HasComponent<T> as saying: “Contract whose state is T has the upgradable component”.

  • Upgradable is annotated with the embeddable_as(<name>) attribute:

    embeddable_as is similar to embeddable; it only applies to impls of starknet interface traits and allows embedding this impl in a contract module. That said,embeddable_as(<name>) has another role in the context of components. Eventually, when embedding Upgradable in some contract, we expect to get an impl with the following function:

    fn upgrade(ref self: ContractState, new_class_hash: ClassHash)
    

    Note that while starting with a function receiving the generic type ComponentState<TContractState>, we want to end up with a function receiving ContractState. This is where embeddable_as(<name>) comes in. To see the full picture, we need to see what is the impl generated by the compiler due to the embeddable_as(UpgradableImpl) annotation:

    // generated
    #[starknet::embeddable]
    impl UpgradableImpl<TContractState, +HasComponent<TContractState>> of UpgradableTrait<TContractState> {
        fn upgrade(ref self: TContractState, new_class_hash: ClassHash) {
            let mut component = self.get_component_mut();
            component.upgrade(new_class_hash, )
        }
    }
    

    Note that thanks to having an impl of HasComponent<TContractState>, the compiler was able to wrap our upgrade function in a new impl that doesn’t directly know about the ComponentState type. UpgradableImpl, whose name we chose when writing embeddable_as(UpgradableImpl), is the impl that we will embed in a contract that wants upgradability.

To complete the picture, we look at the following lines inside counter_contract.cairo

#[abi(embed_v0)]
impl Upgradable = upgradable_component::UpgradableImpl<ContractSate>; 

We’ve seen how UpgradableImpl was generated by the compiler inside upgradable.cairo. The above lines use the Cairo v2.3.0 impl embedding mechanism alongside the impl alias syntax. We’re instantiating the generic UpgradableImpl<TContractState> with the concrete type ContractState. Recall that UpgradableImpl<TContractState> has the HasComponent<TContractState> generic impl param. An implementation of this trait is generated by the component! macro. Note that only the using contract could have implemented this trait since only it knows about both the contract state and the component state.

Thank you for this awesome new feature.

Is #[substorage(v0)] will make the compiler to raise an error on storage name clash please?

thanks for your reply!

substorage essentially means that it’s not accessed directly, like other storage variables whose type implement the Store trait. For example, if I want to access upgradable’s storage from the using contract, I do self.upgradable.current_implementation.read().

Regarding your question, yes, the compiler will give an error if different components (or the contract itself) collide on some storage variable name. With v0 we still determine the layout in storage solely based on the variable name. This is important for upgradability purposes, where you want to use components but have the storage match exactly the non-component version. We plan to release v1 in the future where the name will also be determined by the “component name”, so you can have identical names in different components but they will not collide inside the storage.

Thank you for the answer, the v1 will be awesome.

With the 2.2.0 version we used to make composability using unsafe contract states, it was not secure espcially for storage variable names clashes I guess, but it brings a lot of flexibility.

With the component feature, and after a first read (I didn’t tried yet) I feel less comfortable regarding this flexibility. I am questioning the fact that we have to change a component regarding the contract usage I will have when I would like my component to be very neutral/generic. An example is worth a thousand words, I used to do this in my contracts to manage access control:

#[starknet::contract]
mod contract {
    ...
    #[external(v0)]
    impl UpgradeableImpl of IUpgradeable<ContractState> {
        fn upgrade(ref self: ContractState, impl_hash: ClassHash) {
            // [Check] Only owner
            let unsafe_state = Ownable::unsafe_new_contract_state();
            Ownable::InternalImpl::assert_only_owner(@unsafe_state);
            // [Effect] Upgrade
            let mut unsafe_state = Upgradeable::unsafe_new_contract_state();
            Upgradeable::InternalImpl::_upgrade(ref unsafe_state, impl_hash)
        }
    }
    ...
}

The example you provided is feature parity with this instance but what if I wanted to manage my access control with some custom controls (from OZ AccessControl module for instance). Am I supposed to have a dedicated component for each usage I need in my different contract?

Not sure if I correctly understand what you’re trying to do, but I think dependencies achieve what you want. Let’s say that AccessControl is a trait that manages access according to the logic you want, then if the component is dependent on an implementation of AccessControl<TContractState>, then it doesn’t care how your contract got it. As long as such an implementation exists, you can use the component.

@FeedTheFed as always, thanks for those very insightful posts!
Will test this in order to make more feedback. It looks awesome. :rocket:

Thank you, so if I understand well and if I have one contract that implement AccessControl and another that implements Ownable, I will need to have 2 dedicated components? This was the topic I was trying to point out, using unsafe contract state strategy we are able to implement a single generic component for this purpose.

You will probably need two components, although you can write one component that fits your purpose.

This repo contains a few more elaborate dependency examples that you can check out.

Awesome feature !

I saw that there is a macro get_dep_component_mut! which seems to do the same thing as the get_component_mut() function. I tried to implement both to understand the difference but couldn’t find one. Are they similar or is there a real difference between them?

With macro get_dep_component_mut! :

# [embeddable_as (SimpleContractImpl)]
impl SimpleContract<
   TContractState,
   +HasComponent<TContractState>,
   impl Upgradeable: UpgradeableComponent::HasComponent<TContractState>,
   +Drop<TContractState>
> of super::ISimpleContract<ComponentState<TContractState>> {
   ...
   fn upgrade(ref self: ComponentState<TContractState>, new_class_hash: ClassHash) {
      let mut upgradeable_component = get_dep_component_mut!(ref self, Upgradeable);
      upgradeable_component._upgrade(new_class_hash);
   }
}

With function get_component_mut() :

# [embeddable_as (SimpleContractImpl)]
impl SimpleContract<
   TContractState,
   +HasComponent<TContractState>,
   +UpgradeableComponent::HasComponent<TContractState>,
   +Drop<TContractState>
> of super::ISimpleContract<ComponentState<TContractState>> {
   ...
   fn upgrade(ref self: ComponentState<TContractState>, new_class_hash: ClassHash) {
      let mut contract = self.get_contract_mut();
      let mut upgradeable_component = UpgradeableComponent::HasComponent::< 
         TContractState
      >::get_component_mut(ref contract);
      upgradeable_component._upgrade(new_class_hash);
   }
}

get_dep_component_mut! returns a ComponentState as opposed to @ComponentState, so you can use the component’s non-view functions.

Note that for now both macros only work on a mutable input (i.e. you can’t use them in a view function). It makes sense for get_dep_component! to take a snapshot as input, we’ll try to address this in the next release.

Is this available now?

yes, this was fixed in 2.5.x, get_dep_component now expects a snapshot