Components
The following documentation provides reasoning and examples on how to use Contracts for Cairo components.
Starknet components are separate modules that contain storage, events, and implementations that can be integrated into a contract. Components themselves cannot be declared or deployed. Another way to think of components is that they are abstract modules that must be instantiated.
For more information on the construction and design of Starknet components, see the Starknet Shamans post and the Cairo book. |
Building a contract
Setup
The contract should first import the component and declare it with the component!
macro:
#[starknet::contract]
mod MyContract {
// Import the component
use openzeppelin::security::InitializableComponent;
// Declare the component
component!(path: InitializableComponent, storage: initializable, event: InitializableEvent);
}
The path
argument should be the imported component itself (in this case, InitializableComponent).
The storage
and event
arguments are the variable names that will be set in the Storage
struct and Event
enum, respectively.
Note that even if the component doesn’t define any events, the compiler will still create an empty event enum inside the component module.
#[starknet::contract]
mod MyContract {
use openzeppelin::security::InitializableComponent;
component!(path: InitializableComponent, storage: initializable, event: InitializableEvent);
#[storage]
struct Storage {
#[substorage(v0)]
initializable: InitializableComponent::Storage
}
#[event]
#[derive(Drop, starknet::Event)]
enum Event {
#[flat]
InitializableEvent: InitializableComponent::Event
}
}
The #[substorage(v0)]
attribute must be included for each component in the Storage
trait.
This allows the contract to have indirect access to the component’s storage.
See Accessing component storage for more on this.
The #[flat]
attribute for events in the Event
enum, however, is not required.
For component events, the first key in the event log is the component ID.
Flattening the component event removes it, leaving the event ID as the first key.
Implementations
Components come with granular implementations of different interfaces. This allows contracts to integrate only the implementations that they’ll use and avoid unnecessary bloat. Integrating an implementation looks like this:
mod MyContract {
use openzeppelin::security::InitializableComponent;
component!(path: InitializableComponent, storage: initializable, event: InitializableEvent);
(...)
// Gives the contract access to the implementation methods
impl InitializableImpl =
InitializableComponent::InitializableImpl<ContractState>;
}
Defining an impl
gives the contract access to the methods within the implementation from the component.
For example, is_initialized
is defined in the InitializableImpl
.
A function on the contract level can expose it like this:
#[starknet::contract]
mod MyContract {
use openzeppelin::security::InitializableComponent;
component!(path: InitializableComponent, storage: initializable, event: InitializableEvent);
(...)
impl InitializableImpl =
InitializableComponent::InitializableImpl<ContractState>;
#[external(v0)]
fn is_initialized(ref self: ContractState) -> bool {
self.initializable.is_initialized()
}
}
While there’s nothing wrong with manually exposing methods like in the previous example, this process can be tedious for implementations with many methods.
Fortunately, a contract can embed implementations which will expose all of the methods of the implementation.
To embed an implementation, add the #[abi(embed_v0)]
attribute above the impl
:
#[starknet::contract]
mod MyContract {
(...)
// This attribute exposes the methods of the `impl`
#[abi(embed_v0)]
impl InitializableImpl =
InitializableComponent::InitializableImpl<ContractState>;
}
InitializableImpl
defines the is_initialized
method in the component.
By adding the embed attribute, is_initialized
becomes a contract entrypoint for MyContract
.
Embeddable implementations, when available in this library’s components, are segregated from the internal component implementation which makes it easier to safely expose. Components also separate granular implementations from mixin implementations. The API documentation design reflects these groupings. See ERC20Component as an example which includes:
|
Mixins
Mixins are impls made of a combination of smaller, more specific impls. While separating components into granular implementations offers flexibility, integrating components with many implementations can appear crowded especially if the contract uses all of them. Mixins simplify this by allowing contracts to embed groups of implementations with a single directive.
Compare the following code blocks to see the benefit of using a mixin when creating an account contract.
Account without mixin
component!(path: AccountComponent, storage: account, event: AccountEvent);
component!(path: SRC5Component, storage: src5, event: SRC5Event);
#[abi(embed_v0)]
impl SRC6Impl = AccountComponent::SRC6Impl<ContractState>;
#[abi(embed_v0)]
impl DeclarerImpl = AccountComponent::DeclarerImpl<ContractState>;
#[abi(embed_v0)]
impl DeployableImpl = AccountComponent::DeployableImpl<ContractState>;
#[abi(embed_v0)]
impl PublicKeyImpl = AccountComponent::PublicKeyImpl<ContractState>;
#[abi(embed_v0)]
impl SRC6CamelOnlyImpl = AccountComponent::SRC6CamelOnlyImpl<ContractState>;
#[abi(embed_v0)]
impl PublicKeyCamelImpl = AccountComponent::PublicKeyCamelImpl<ContractState>;
impl AccountInternalImpl = AccountComponent::InternalImpl<ContractState>;
#[abi(embed_v0)]
impl SRC5Impl = SRC5Component::SRC5Impl<ContractState>;
Account with mixin
component!(path: AccountComponent, storage: account, event: AccountEvent);
component!(path: SRC5Component, storage: src5, event: SRC5Event);
#[abi(embed_v0)]
impl AccountMixinImpl = AccountComponent::AccountMixinImpl<ContractState>;
impl AccountInternalImpl = AccountComponent::InternalImpl<ContractState>;
The rest of the setup for the contract, however, does not change.
This means that component dependencies must still be included in the Storage
struct and Event
enum.
Here’s a full example of an account contract that embeds the AccountMixinImpl
:
#[starknet::contract]
mod Account {
use openzeppelin::account::AccountComponent;
use openzeppelin::introspection::src5::SRC5Component;
component!(path: AccountComponent, storage: account, event: AccountEvent);
component!(path: SRC5Component, storage: src5, event: SRC5Event);
// This embeds all of the methods from the many AccountComponent implementations
// and also includes `supports_interface` from `SRC5Impl`
#[abi(embed_v0)]
impl AccountMixinImpl = AccountComponent::AccountMixinImpl<ContractState>;
impl AccountInternalImpl = AccountComponent::InternalImpl<ContractState>;
#[storage]
struct Storage {
#[substorage(v0)]
account: AccountComponent::Storage,
#[substorage(v0)]
src5: SRC5Component::Storage
}
#[event]
#[derive(Drop, starknet::Event)]
enum Event {
#[flat]
AccountEvent: AccountComponent::Event,
#[flat]
SRC5Event: SRC5Component::Event
}
#[constructor]
fn constructor(ref self: ContractState, public_key: felt252) {
self.account.initializer(public_key);
}
}
Initializers
Failing to use a component’s initializer can result in irreparable contract deployments.
Always read the API documentation for each integrated component.
|
Some components require some sort of setup upon construction.
Usually, this would be a job for a constructor; however, components themselves cannot implement constructors.
Components instead offer initializer
s within their InternalImpl
to call from the contract’s constructor.
Let’s look at how a contract would integrate OwnableComponent:
#[starknet::contract]
mod MyContract {
use openzeppelin::access::ownable::OwnableComponent;
use starknet::ContractAddress;
component!(path: OwnableComponent, storage: ownable, event: OwnableEvent);
// Instantiate `InternalImpl` to give the contract access to the `initializer`
impl InternalImpl = OwnableComponent::InternalImpl<ContractState>;
#[storage]
struct Storage {
#[substorage(v0)]
ownable: OwnableComponent::Storage
}
#[event]
#[derive(Drop, starknet::Event)]
enum Event {
#[flat]
OwnableEvent: OwnableComponent::Event
}
#[constructor]
fn constructor(ref self: ContractState, owner: ContractAddress) {
// Invoke ownable's `initializer`
self.ownable.initializer(owner);
}
}
Immutable Config
While initializers help set up the component’s initial state, some require configuration that may be defined as constants, saving gas by avoiding the necessity of reading from storage each time the variable needs to be used. The Immutable Component Config pattern helps with this matter by allowing the implementing contract to define a set of constants declared in the component, customizing its functionality.
The Immutable Component Config standard is defined in the SRC-107. |
Here’s an example of how to use the Immutable Component Config pattern with the ERC2981Component:
#[starknet::contract]
mod MyContract {
use openzeppelin::introspection::src5::SRC5Component;
use openzeppelin::token::common::erc2981::ERC2981Component;
use starknet::contract_address_const;
component!(path: ERC2981Component, storage: erc2981, event: ERC2981Event);
component!(path: SRC5Component, storage: src5, event: SRC5Event);
// SRC5
#[abi(embed_v0)]
impl SRC5Impl = SRC5Component::SRC5Impl<ContractState>;
// Instantiate `InternalImpl` to give the contract access to the `initializer`
impl InternalImpl = ERC2981Component::InternalImpl<ContractState>;
#[storage]
struct Storage {
#[substorage(v0)]
erc2981: ERC2981Component::Storage,
#[substorage(v0)]
src5: SRC5Component::Storage
}
#[event]
#[derive(Drop, starknet::Event)]
enum Event {
#[flat]
ERC2981Event: ERC2981Component::Event,
#[flat]
SRC5Event: SRC5Component::Event
}
// Define the immutable config
pub impl ERC2981ImmutableConfig of ERC2981Component::ImmutableConfig {
const FEE_DENOMINATOR: u128 = 10_000;
}
#[constructor]
fn constructor(ref self: ContractState) {
let default_receiver = contract_address_const::<'RECEIVER'>();
let default_royalty_fraction = 1000;
// Invoke erc2981's `initializer`
self.erc2981.initializer(default_receiver, default_royalty_fraction);
}
}
Default config
Sometimes, components implementing the Immutable Component Config pattern provide a default configuration that can be
directly used without implementing the ImmutableConfig
trait locally. When provided, this implementation will be named
DefaultConfig
and will be available in the same module containing the component, as a sibling.
In the following example, the DefaultConfig
trait is used to define the FEE_DENOMINATOR
config constant.
#[starknet::contract]
mod MyContract {
use openzeppelin::introspection::src5::SRC5Component;
// Bring the DefaultConfig trait into scope
use openzeppelin::token::common::erc2981::{ERC2981Component, DefaultConfig};
use starknet::contract_address_const;
component!(path: ERC2981Component, storage: erc2981, event: ERC2981Event);
component!(path: SRC5Component, storage: src5, event: SRC5Event);
// SRC5
#[abi(embed_v0)]
impl SRC5Impl = SRC5Component::SRC5Impl<ContractState>;
// Instantiate `InternalImpl` to give the contract access to the `initializer`
impl InternalImpl = ERC2981Component::InternalImpl<ContractState>;
#[storage]
struct Storage {
(...)
}
#[event]
#[derive(Drop, starknet::Event)]
enum Event {
(...)
}
#[constructor]
fn constructor(ref self: ContractState) {
let default_receiver = contract_address_const::<'RECEIVER'>();
let default_royalty_fraction = 1000;
// Invoke erc2981's `initializer`
self.erc2981.initializer(default_receiver, default_royalty_fraction);
}
}
validate
function
The ImmutableConfig
trait may also include a validate
function with a default implementation, which
asserts that the configuration is correct, and must not be overridden by the implementing contract. For more information
on how to use this function, refer to the validate section of the SRC-107.
Dependencies
Some components include dependencies of other components.
Contracts that integrate components with dependencies must also include the component dependency.
For instance, AccessControlComponent depends on SRC5Component.
Creating a contract with AccessControlComponent
should look like this:
#[starknet::contract]
mod MyContract {
use openzeppelin::access::accesscontrol::AccessControlComponent;
use openzeppelin::introspection::src5::SRC5Component;
component!(path: AccessControlComponent, storage: accesscontrol, event: AccessControlEvent);
component!(path: SRC5Component, storage: src5, event: SRC5Event);
// AccessControl
#[abi(embed_v0)]
impl AccessControlImpl =
AccessControlComponent::AccessControlImpl<ContractState>;
#[abi(embed_v0)]
impl AccessControlCamelImpl =
AccessControlComponent::AccessControlCamelImpl<ContractState>;
impl AccessControlInternalImpl = AccessControlComponent::InternalImpl<ContractState>;
// SRC5
#[abi(embed_v0)]
impl SRC5Impl = SRC5Component::SRC5Impl<ContractState>;
#[storage]
struct Storage {
#[substorage(v0)]
accesscontrol: AccessControlComponent::Storage,
#[substorage(v0)]
src5: SRC5Component::Storage
}
#[event]
#[derive(Drop, starknet::Event)]
enum Event {
#[flat]
AccessControlEvent: AccessControlComponent::Event,
#[flat]
SRC5Event: SRC5Component::Event
}
(...)
}
Customization
Customizing implementations and accessing component storage can potentially corrupt the state, bypass security checks, and undermine the component logic. Exercise extreme caution. See Security. |
Hooks
Hooks are entrypoints to the business logic of a token component that are accessible at the contract level. This allows contracts to insert additional behaviors before and/or after token transfers (including mints and burns). Prior to hooks, extending functionality required contracts to create custom implementations.
All token components include a generic hooks trait that include empty default functions.
When creating a token contract, the using contract must create an implementation of the hooks trait.
Suppose an ERC20 contract wanted to include Pausable functionality on token transfers.
The following snippet leverages the before_update
hook to include this behavior.
#[starknet::contract]
mod MyToken {
use openzeppelin::security::pausable::PausableComponent::InternalTrait;
use openzeppelin::security::pausable::PausableComponent;
use openzeppelin::token::erc20::ERC20Component;
use starknet::ContractAddress;
component!(path: ERC20Component, storage: erc20, event: ERC20Event);
component!(path: PausableComponent, storage: pausable, event: PausableEvent);
// ERC20 Mixin
#[abi(embed_v0)]
impl ERC20MixinImpl = ERC20Component::ERC20MixinImpl<ContractState>;
impl ERC20InternalImpl = ERC20Component::InternalImpl<ContractState>;
#[abi(embed_v0)]
impl PausableImpl = PausableComponent::PausableImpl<ContractState>;
impl PausableInternalImpl = PausableComponent::InternalImpl<ContractState>;
// Create the hooks implementation
impl ERC20HooksImpl of ERC20Component::ERC20HooksTrait<ContractState> {
// Occurs before token transfers
fn before_update(
ref self: ERC20Component::ComponentState<ContractState>,
from: ContractAddress,
recipient: ContractAddress,
amount: u256
) {
// Access local state from component state
let contract_state = ERC20Component::HasComponent::get_contract(@self);
// Call function from integrated component
contract_state.pausable.assert_not_paused();
}
// Omitting the `after_update` hook because the default behavior
// is already implemented in the trait
}
(...)
}
Notice that the self
parameter expects a component state type.
Instead of passing the component state, the using contract’s state can be passed which simplifies the syntax.
The hook then moves the scope up with the Cairo-generated get_contract
through the HasComponent
trait (as illustrated with ERC20Component in this example).
From here, the hook can access the using contract’s integrated components, storage, and implementations.
Be advised that even if a token contract does not require hooks, the hooks trait must still be implemented. The using contract may instantiate an empty impl of the trait; however, the Contracts for Cairo library already provides the instantiated impl to abstract this away from contracts. The using contract just needs to bring the implementation into scope like this:
#[starknet::contract]
mod MyToken {
use openzeppelin::token::erc20::ERC20Component;
use openzeppelin::token::erc20::ERC20HooksEmptyImpl;
(...)
}
For a more in-depth guide on hooks, see Extending Cairo Contracts with Hooks. |
Custom implementations
There are instances where a contract requires different or amended behaviors from a component implementation. In these scenarios, a contract must create a custom implementation of the interface. Let’s break down a pausable ERC20 contract to see what that looks like. Here’s the setup:
#[starknet::contract]
mod ERC20Pausable {
use openzeppelin::security::pausable::PausableComponent;
use openzeppelin::token::erc20::{ERC20Component, ERC20HooksEmptyImpl};
// Import the ERC20 interfaces to create custom implementations
use openzeppelin::token::erc20::interface::{IERC20, IERC20CamelOnly};
use starknet::ContractAddress;
component!(path: PausableComponent, storage: pausable, event: PausableEvent);
component!(path: ERC20Component, storage: erc20, event: ERC20Event);
#[abi(embed_v0)]
impl PausableImpl = PausableComponent::PausableImpl<ContractState>;
impl PausableInternalImpl = PausableComponent::InternalImpl<ContractState>;
// `ERC20MetadataImpl` can keep the embed directive because the implementation
// will not change
#[abi(embed_v0)]
impl ERC20MetadataImpl = ERC20Component::ERC20MetadataImpl<ContractState>;
// Do not add the embed directive to these implementations because
// these will be customized
impl ERC20Impl = ERC20Component::ERC20Impl<ContractState>;
impl ERC20CamelOnlyImpl = ERC20Component::ERC20CamelOnlyImpl<ContractState>;
impl ERC20InternalImpl = ERC20Component::InternalImpl<ContractState>;
(...)
}
The first thing to notice is that the contract imports the interfaces of the implementations that will be customized. These will be used in the next code example.
Next, the contract includes the ERC20Component implementations; however, ERC20Impl
and ERC20CamelOnlyImplt
are not embedded.
Instead, we want to expose our custom implementation of an interface.
The following example shows the pausable logic integrated into the ERC20 implementations:
#[starknet::contract]
mod ERC20Pausable {
(...)
// Custom ERC20 implementation
#[abi(embed_v0)]
impl CustomERC20Impl of IERC20<ContractState> {
fn transfer(
ref self: ContractState, recipient: ContractAddress, amount: u256
) -> bool {
// Add the custom logic
self.pausable.assert_not_paused();
// Add the original implementation method from `IERC20Impl`
self.erc20.transfer(recipient, amount)
}
fn total_supply(self: @ContractState) -> u256 {
// This method's behavior does not change from the component
// implementation, but this method must still be defined.
// Simply add the original implementation method from `IERC20Impl`
self.erc20.total_supply()
}
(...)
}
// Custom ERC20CamelOnly implementation
#[abi(embed_v0)]
impl CustomERC20CamelOnlyImpl of IERC20CamelOnly<ContractState> {
fn totalSupply(self: @ContractState) -> u256 {
self.erc20.total_supply()
}
fn balanceOf(self: @ContractState, account: ContractAddress) -> u256 {
self.erc20.balance_of(account)
}
fn transferFrom(
ref self: ContractState,
sender: ContractAddress,
recipient: ContractAddress,
amount: u256
) -> bool {
self.pausable.assert_not_paused();
self.erc20.transfer_from(sender, recipient, amount)
}
}
}
Notice that in the CustomERC20Impl
, the transfer
method integrates pausable.assert_not_paused
as well as erc20.transfer
from PausableImpl
and ERC20Impl
respectively.
This is why the contract defined the ERC20Impl
from the component in the previous example.
Creating a custom implementation of an interface must define all methods from that interface.
This is true even if the behavior of a method does not change from the component implementation (as total_supply
exemplifies in this example).
The ERC20 documentation provides another custom implementation guide for Customizing decimals. |
Accessing component storage
There may be cases where the contract must read or write to an integrated component’s storage. To do so, use the same syntax as calling an implementation method except replace the name of the method with the storage variable like this:
#[starknet::contract]
mod MyContract {
use openzeppelin::security::InitializableComponent;
component!(path: InitializableComponent, storage: initializable, event: InitializableEvent);
#[storage]
struct Storage {
#[substorage(v0)]
initializable: InitializableComponent::Storage
}
(...)
fn write_to_comp_storage(ref self: ContractState) {
self.initializable.Initializable_initialized.write(true);
}
fn read_from_comp_storage(self: @ContractState) -> bool {
self.initializable.Initializable_initialized.read()
}
}
Security
The maintainers of OpenZeppelin Contracts for Cairo are mainly concerned with the correctness and security of the code as published in the library.
Customizing implementations and manipulating the component state may break some important assumptions and introduce vulnerabilities. While we try to ensure the components remain secure in the face of a wide range of potential customizations, this is done in a best-effort manner. Any and all customizations to the component logic should be carefully reviewed and checked against the source code of the component they are customizing so as to fully understand their impact and guarantee their security.