Components: Lego-Like Building Blocks for Smart Contracts
Developing contracts sharing a common logic and storage can be painful and bug-prone, as this logic can hardly be reused and needs to be reimplemented in each contract. But what if there was a way to snap in just the extra functionality you need inside your contract, separating the core logic of your contract from the rest?
Components provide exactly that. They are modular add-ons encapsulating reusable logic, storage, and events that can be incorporated into multiple contracts. They can be used to extend a contract's functionality, without having to reimplement the same logic over and over again.
Think of components as Lego blocks. They allow you to enrich your contracts by plugging in a module that you or someone else wrote. This module can be a simple one, like an ownership component, or more complex like a full-fledged ERC20 token.
A component is a separate module that can contain storage, events, and functions. Unlike a contract, a component cannot be declared or deployed. Its logic will eventually be part of the contract’s bytecode it has been embedded in.
What's in a Component?
A component is very similar to a contract. It can contain:
- Storage variables
- Events
- External and internal functions
Unlike a contract, a component cannot be deployed on its own. The component's code becomes part of the contract it's embedded to.
Creating Components
To create a component, first define it in its own module decorated with a
#[starknet::component] attribute. Within this module, you can declare a
Storage struct and Event enum, as usually done in
contracts.
The next step is to define the component interface, containing the signatures of
the functions that will allow external access to the component's logic. You can
define the interface of the component by declaring a trait with the
#[starknet::interface] attribute, just as you would with contracts. This
interface will be used to enable external access to the component's functions
using the dispatcher pattern.
The actual implementation of the component's external logic is done in an impl
block marked as #[embeddable_as(name)]. Usually, this impl block will be an
implementation of the trait defining the interface of the component.
Note:
nameis the name that we’ll be using in the contract to refer to the component. It is different than the name of your impl.
You can also define internal functions that will not be accessible externally,
by simply omitting the #[embeddable_as(name)] attribute above the internal
impl block. You will be able to use these internal functions inside the
contract you embed the component in, but not interact with it from outside, as
they're not a part of the abi of the contract.
Functions within these impl block expect arguments like
ref self: ComponentState<TContractState> (for state-modifying functions) or
self: @ComponentState<TContractState> (for view functions). This makes the
impl generic over TContractState, allowing us to use this component in any
contract.
Example: an Ownable Component
⚠️ The example shown below has not been audited and is not intended for production use. The authors are not responsible for any damages caused by the use of this code.
The interface of the Ownable component, defining the methods available externally to manage ownership of a contract, would look like this:
#[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);
}
The component itself is defined as:
#[starknet::component]
pub mod OwnableComponent {
use core::num::traits::Zero;
use starknet::storage::{StoragePointerReadAccess, StoragePointerWriteAccess};
use starknet::{ContractAddress, get_caller_address};
use super::Errors;
#[storage]
pub struct Storage {
owner: ContractAddress,
}
#[event]
#[derive(Drop, starknet::Event)]
pub enum Event {
OwnershipTransferred: OwnershipTransferred,
}
#[derive(Drop, starknet::Event)]
struct OwnershipTransferred {
previous_owner: ContractAddress,
new_owner: ContractAddress,
}
#[embeddable_as(OwnableImpl)]
impl Ownable<
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());
}
}
#[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 },
);
}
}
}
This syntax is actually quite similar to the syntax used for contracts. The only
differences relate to the #[embeddable_as] attribute above the impl and the
genericity of the impl block that we will dissect in details.
As you can see, our component has two impl blocks: one corresponding to the
implementation of the interface trait, and one containing methods that should
not be exposed externally and are only meant for internal use. Exposing the
assert_only_owner as part of the interface wouldn't make sense, as it's only
meant to be used internally by a contract embedding the component.
A Closer Look at the impl Block
#[embeddable_as(OwnableImpl)]
impl Ownable<
TContractState, +HasComponent<TContractState>,
> of super::IOwnable<ComponentState<TContractState>> {
The #[embeddable_as] attribute is used to mark the impl as embeddable inside a
contract. It allows us to specify the name of the impl that will be used in the
contract to refer to this component. In this case, the component will be
referred to as OwnableImpl in contracts embedding it.
The implementation itself is generic over ComponentState<TContractState>, with
the added restriction that TContractState must implement the HasComponent<T>
trait. This allows us to use the component in any contract, as long as the
contract implements the HasComponent trait. Understanding this mechanism in
details is not required to use components, but if you're curious about the inner
workings, you can read more in the "Components Under the
Hood" section.
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(...).
Note: To avoid confusion, follow OpenZeppelin’s pattern: keep the
Implsuffix in the embeddable name and in the contract’s impl alias (e.g.,OwnableImpl), while the local component impl is named after the trait without the suffix (e.g.,impl Ownable<...>).
Migrating a Contract to a Component
Since both contracts and components share a lot of similarities, it's actually very easy to migrate from a contract to a component. The only changes required are:
- Adding the
#[starknet::component]attribute to the module. - Adding the
#[embeddable_as(name)]attribute to theimplblock that will be embedded in another contract. - Adding generic parameters to the
implblock:- Adding
TContractStateas a generic parameter. - Adding
+HasComponent<TContractState>as an impl restriction.
- Adding
- Changing the type of the
selfargument in the functions inside theimplblock toComponentState<TContractState>instead ofContractState.
For traits that do not have an explicit definition and are generated using
#[generate_trait], the logic is the same - but the trait is generic over
TContractState instead of ComponentState<TContractState>, as demonstrated in
the example with the InternalTrait.
Using Components Inside a Contract
The major strength of components is how it allows reusing already built primitives inside your contracts with a restricted amount of boilerplate. To integrate a component into your contract, you need to:
-
Declare it with the
component!()macro, specifying- The path to the component
path::to::component. - The name of the variable in your contract's storage referring to this
component's storage (e.g.
ownable). - The name of the variant in your contract's event enum referring to this
component's events (e.g.
OwnableEvent).
- The path to the component
-
Add the path to the component's storage and events to the contract's
StorageandEvent. They must match the names provided in step 1 (e.g.ownable: ownable_component::StorageandOwnableEvent: ownable_component::Event).The storage variable MUST be annotated with the
#[substorage(v0)]attribute. -
Embed the component's logic defined inside your contract, by instantiating the component's generic impl with a concrete
ContractStateusing an impl alias. This alias must be annotated with#[abi(embed_v0)]to externally expose the component's functions.As you can see, the InternalImpl is not marked with
#[abi(embed_v0)]. Indeed, we don't want to expose externally the functions defined in this impl. However, we might still want to access them internally.
For example, to embed the Ownable component defined above, we would do the
following:
#[starknet::contract]
mod OwnableCounter {
use listing_01_ownable::component::OwnableComponent;
use starknet::storage::{StoragePointerReadAccess, StoragePointerWriteAccess};
component!(path: OwnableComponent, storage: ownable, event: OwnableEvent);
#[abi(embed_v0)]
impl OwnableImpl = OwnableComponent::OwnableImpl<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);
}
}
The component's logic is now seamlessly part of the contract! We can interact
with the components functions externally by calling them using the
IOwnableDispatcher instantiated with the contract's address.
#[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);
}
Stacking Components for Maximum Composability
The composability of components really shines when combining multiple of them together. Each adds its features onto the contract. You can rely on Openzeppelin's implementation of components to quickly plug-in all the common functionalities you need a contract to have.
Developers can focus on their core contract logic while relying on battle-tested and audited components for everything else.
Components can even depend on other components by
restricting the TContractstate they're generic on to implement the trait of
another component. Before we dive into this mechanism, let's first look at how
components work under the hood.