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:

  • Embeddable Mixin Implementation

  • Embeddable Implementations

  • Internal Implementations

  • Events

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

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.

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
    #[external(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
    #[external(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.