Components: Under the Hood
Components provide powerful modularity to Starknet contracts. But how does this magic actually happen behind the scenes?
This chapter will dive deep into the compiler internals to explain the mechanisms that enable component composability.
A Primer on Embeddable Impls
Before digging into components, we need to understand embeddable impls.
An impl of a Starknet interface trait (marked with #[starknet::interface]) can
be made embeddable. Embeddable impls can be injected into any contract, adding
new entry points and modifying the ABI of the contract.
Let's look at an example to see this in action:
#[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>;
}
By embedding SimpleImpl, we externally expose ret4 in the contract's ABI.
Now that we’re more familiar with the embedding mechanism, we can now see how components build on this.
Inside Components: Generic Impls
Recall the impl block syntax used in components:
#[embeddable_as(OwnableImpl)]
impl Ownable<
TContractState, +HasComponent<TContractState>,
> of super::IOwnable<ComponentState<TContractState>> {
The key points:
-
The generic impl
Ownablerequires the implementation of theHasComponent<TContractState>trait by the underlying contract, which is automatically generated with thecomponent!()macro when using a component inside a contract.The compiler will generate an embeddable impl that wraps any function in
Ownable, replacing theself: ComponentState<TContractState>argument withself: TContractState, where access to the component state is made via theget_componentfunction in theHasComponent<TContractState>trait.For each component, the compiler generates a
HasComponenttrait. This trait defines the interface to bridge between the actualTContractStateof a generic contract, andComponentState<TContractState>.// 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 ownable component, i.e. it has members based on the storage variables defined inownable_component::Storage. Moving from the genericTContractStatetoComponentState<TContractState>will allow us to embedOwnableImplin any contract that wants to use it. The opposite direction (ComponentState<TContractState>toContractState) is useful for dependencies (see theUpgradeablecomponent depending on anIOwnableimplementation example in the Components dependencies section).To put it briefly, one should think of an implementation of the above
HasComponent<T>as saying: “Contract whose state T has the upgradeable component”. -
Ownableis annotated with theembeddable_as(<name>)attribute:embeddable_asis similar toembeddable; it only applies to impls ofstarknet::interfacetraits and allows embedding this impl in a contract module. That said,embeddable_as(<name>)has another role in the context of components. Eventually, when embeddingOwnableImplin some contract, we expect to get an impl with the following functions:fn owner(self: @TContractState) -> ContractAddress; fn transfer_ownership(ref self: TContractState, new_owner: ContractAddress); fn renounce_ownership(ref self: TContractState);Note that while starting with a function receiving the generic type
ComponentState<TContractState>, we want to end up with a function receivingContractState. This is whereembeddable_as(<name>)comes in. To see the full picture, we need to see what is the impl generated by the compiler due to theembeddable_as(OwnableImpl)annotation:
#[starknet::embeddable]
impl OwnableImpl<
TContractState, +HasComponent<TContractState>, impl TContractStateDrop: Drop<TContractState>,
> of super::IOwnable<TContractState> {
fn owner(self: @TContractState) -> ContractAddress {
let component = HasComponent::get_component(self);
Ownable::owner(component)
}
fn transfer_ownership(ref self: TContractState, new_owner: ContractAddress) {
let mut component = HasComponent::get_component_mut(ref self);
Ownable::transfer_ownership(ref component, new_owner)
}
fn renounce_ownership(ref self: TContractState) {
let mut component = HasComponent::get_component_mut(ref self);
Ownable::renounce_ownership(ref component)
}
}
Note that thanks to having an impl of HasComponent<TContractState>, the
compiler was able to wrap our functions in a new impl that doesn’t directly know
about the ComponentState type. OwnableImpl, whose name we chose when writing
embeddable_as(OwnableImpl), is the impl that we will embed in a contract that
wants ownership.
Contract Integration
We've seen how generic impls enable component reusability. Next let's see how a contract integrates a component.
The contract uses an impl alias to instantiate the component's generic impl
with the concrete ContractState of the contract.
#[abi(embed_v0)]
impl OwnableImpl = OwnableComponent::OwnableImpl<ContractState>;
impl OwnableInternalImpl = OwnableComponent::InternalImpl<ContractState>;
The above lines use the Cairo impl embedding mechanism alongside the impl alias
syntax. We’re instantiating the embeddable generated impl
OwnableImpl<TContractState> with the concrete type ContractState. Recall
that the generic impl is Ownable<TContractState>, and the component! macro
provides HasComponent<TContractState> so the wrapper can delegate to it.
Note that only the using contract could have implemented this trait since only it knows about both the contract state and the component state.
This glues everything together to inject the component logic into the contract.
Key Takeaways
- Embeddable impls allow injecting components logic into contracts by adding entry points and modifying the contract ABI.
- The compiler automatically generates a
HasComponenttrait implementation when a component is used in a contract. This creates a bridge between the contract's state and the component's state, enabling interaction between the two. - Components encapsulate reusable logic in a generic, contract-agnostic way.
Contracts integrate components through impl aliases and access them via the
generated
HasComponenttrait. - Components build on embeddable impls by defining generic component logic that can be integrated into any contract wanting to use that component. Impl aliases instantiate these generic impls with the contract's concrete storage types.