Components
- Intro
- Writing a component
- Using a component in a contract
- Component dependencies
- Appendix - exposing the component logic, what happens under the hood
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
andEvent
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 theHasComponent<TContractState>
traitThis 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 theself: ComponentState<TContractState>
argument withself: TContractState
, where access to the component state is made viaget_component
function in theHasComponent<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 inupgradable_component::Storage
. Moving from the genericTContractState
toComponentState<TContractState>
will allow us to embedUpgradable
in any contract that wants to use it. The opposite direction (ComponentState<TContractState>
toContractState
) is useful for dependencies (see theUpgradable
component depending on anOwnableTrait
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 theembeddable_as(<name>)
attribute:embeddable_as
is similar toembeddable
; 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 embeddingUpgradable
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 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(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 ourupgrade
function in a new impl that doesn’t directly know about theComponentState
type.UpgradableImpl
, whose name we chose when writingembeddable_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.