Extending Cairo Contracts with Hooks

Andrew Fleming
5 min readMay 27, 2024

We’re excited to introduce hooks in OpenZeppelin Contracts for Cairo. Hooks are included in the v0.13.0 release and simplifies the integration of new behaviors in token contracts, thus making your contracts easier to write, read, and maintain.

(The v0.12.0 release included hooks but only for the ERC20 component)

What Are Hooks?

Hooks are entry points to the business logic of a component that are accessible from the contract level. Prior to hooks, customizing an implementation required that contracts redefine the entire implementation. Hooks help ameliorate this process by “hooking” directly into the business logic—specifically, the internal _update function that handles transfers, mints, and burns in all Contracts for Cairo token contracts (ERC20, ERC721, and ERC1155). It’s important to note that hooks do not execute for other state changes such as approvals.

Hooks in Action

Let’s compare ERC20 token snippets to see the advantage of using hooks.

Without hooks

Without using hooks, the following code must reimplement the entire IERC20 interface in order to extend the behavior of the transfer and transfer_from functions. Furthermore, the contract must also reimplement the IERC20Camel interface because these functions call their camel_case variants in the component.

component!(path: ERC20Component, storage: erc20, event: ERC20Event);

#[abi(embed_v0)]
impl ERC20Impl of interface::IERC20<ContractState> {
fn total_supply(self: @ContractState) -> u256 {
self.erc20.total_supply()
}

fn balance_of(self: @ContractState, account: ContractAddress) -> u256 {
self.erc20.balance_of(account)
}

fn allowance(self: @ContractState, owner: ContractAddress, spender: ContractAddress) -> u256 {
self.erc20.allowance(owner, spender)
}

fn transfer(ref self: ContractState, recipient: ContractAddress, amount: u256) -> bool {
// Some additional behavior before the transfer

let result = self.erc20.transfer(recipient, amount);

// Some additional behavior after the transfer

result
}

fn transfer_from(
ref self: ContractState,
sender: ContractAddress,
recipient: ContractAddress,
amount: u256,
) -> bool {
// Some additional behavior before the transfer

let result = self.erc20.transfer_from(sender, recipient, amount);

// Some additional behavior after the transfer

result
}

fn approve(ref self: ContractState, spender: ContractAddress, amount: u256) -> bool {
self.erc20.approve(spender, amount)
}
}

#[abi(embed_v0)]
impl ERC20CamelOnlyImpl of interface::IERC20CamelOnly<ContractState> {
fn totalSupply(self: @ContractState) -> u256 {
self.total_supply()
}

fn balanceOf(self: @ContractState, account: ContractAddress) -> u256 {
self.balance_of(account)
}

fn transferFrom(
ref self: ContractState,
sender: ContractAddress,
recipient: ContractAddress,
amount: u256,
) -> bool {
self.transfer_from(sender, recipient, amount)
}
}

With hooks

The following snippet uses hooks and achieves the same functionality as the verbose code above.

component!(path: ERC20Component, storage: erc20, event: ERC20Event);

// Hooks
impl ERC20HooksImpl of ERC20Component::ERC20HooksTrait<ContractState> {
fn before_update(
ref self: ERC20Component::ComponentState<ContractState>,
from: ContractAddress,
recipient: ContractAddress,
amount: u256
) {
// Some additional behavior before the transfer
}

fn after_update(
ref self: ERC20Component::ComponentState<ContractState>,
from: ContractAddress,
recipient: ContractAddress,
amount: u256
) {
// Some additional behavior after the transfer
}
}

// ERC20 Mixin
#[abi(embed_v0)]
impl ERC20MixinImpl = ERC20Component::ERC20MixinImpl<ContractState>;

Using Hooks in Contracts

In order to utilize the new hooks feature, contracts must implement the hooks trait which includes the before_update and after_update functions and pass in the contract’s state.

Here’s a complete example of a pausable ERC20 contract that leverages before_update to disallow token transfers when the contract is paused.

#[starknet::contract]
mod MyTokenWithHooks {
use openzeppelin::access::ownable::OwnableComponent;
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: OwnableComponent, storage: ownable, event: OwnableEvent);
component!(path: PausableComponent, storage: pausable, event: PausableEvent);

// Ownable Mixin
#[abi(embed_v0)]
impl OwnableMixinImpl = OwnableComponent::OwnableMixinImpl<ContractState>;
impl OwnableInternalImpl = OwnableComponent::InternalImpl<ContractState>;

#[abi(embed_v0)]
impl PausableImpl = PausableComponent::PausableImpl<ContractState>;
impl PausableInternalImpl = PausableComponent::InternalImpl<ContractState>;

// ERC20 Mixin
#[abi(embed_v0)]
impl ERC20MixinImpl = ERC20Component::ERC20MixinImpl<ContractState>;
impl ERC20InternalImpl = ERC20Component::InternalImpl<ContractState>;


#[storage]
struct Storage {
#[substorage(v0)]
ownable: OwnableComponent::Storage,
#[substorage(v0)]
pausable: PausableComponent::Storage,
#[substorage(v0)]
erc20: ERC20Component::Storage
}

#[event]
#[derive(Drop, starknet::Event)]
enum Event {
#[flat]
OwnableEvent: OwnableComponent::Event,
#[flat]
PausableEvent: PausableComponent::Event,
#[flat]
ERC20Event: ERC20Component::Event
}

//
// Hooks
//

impl ERC20HooksImpl of ERC20Component::ERC20HooksTrait<ContractState> {
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);
// Function from integrated component
contract_state.pausable.assert_not_paused();
}

fn after_update(
ref self: ERC20Component::ComponentState<ContractState>,
from: ContractAddress,
recipient: ContractAddress,
amount: u256
) {}
}

#[constructor]
fn constructor(
ref self: ContractState,
fixed_supply: u256,
recipient: ContractAddress,
owner: ContractAddress
) {
self.erc20.initializer("MyToken", "TKN");
self.erc20._mint(recipient, fixed_supply);
self.ownable.initializer(owner);
}

#[generate_trait]
#[abi(per_item)]
impl ExternalImpl of ExternalTrait {
#[external(v0)]
fn pause(ref self: ContractState) {
self.ownable.assert_only_owner();
self.pausable._pause();
}

#[external(v0)]
fn unpause(ref self: ContractState) {
self.ownable.assert_only_owner();
self.pausable._unpause();
}
}
}

Notice that the self parameter expects a component state type. In order to access the using contract’s state after we pass the component state, we must use ERC20Component’s HasComponent trait and move the scope up via the Cairo-generated get_contract function. This allows the hook to access the using contract’s integrated components, storage, and impls.

To further illustrate the idea, let’s say we added counter to the contract’s storage and we wanted to count how many times the after_update hook was called.

impl ERC20HooksImpl of ERC20Component::ERC20HooksTrait<ContractState> {
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);
// Function from integrated component
contract_state.pausable.assert_not_paused();
}

fn after_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_mut(ref self);
// Function from implementation within the contract state's scope
let count = contract_state.get_count();
// Storage access within the contract state's scope
contract_state.counter.write(count + 1);
}
}

#[generate_trait]
impl ExternalImpl of ExternalTrait {
#[external(v0)]
fn get_count(self: @ContractState) -> felt252 {
self.counter.read()
}
}

The only real difference between implementing hooks and implementing other traits is how we access the using contract’s state.

What about token contracts that don’t require additional logic?

For contracts that do not need to extend the behavior of token transfers, contracts can implement the hooks trait with empty functions like this:

impl ERC20HooksImpl of ERC20Component::ERC20HooksTrait<ContractState> {
fn before_update(
ref self: ERC20Component::ComponentState<ContractState>,
from: ContractAddress,
recipient: ContractAddress,
amount: u256
) {}

fn after_update(
ref self: ERC20Component::ComponentState<ContractState>,
from: ContractAddress,
recipient: ContractAddress,
amount: u256
) {}
}

While there’s nothing wrong with this approach, this can appear a bit redundant. To further simplify basic contracts, we offer default empty hooks implementations. Contracts just need to bring the empty implementation into scope like this:

#[starknet::contract]
mod MyBasicToken{
use openzeppelin::token::erc20::ERC20Component;
// Bring empty hooks impl into scope
use openzeppelin::token::erc20::ERC20HooksEmptyImpl;
use starknet::ContractAddress;

component!(path: ERC20Component, storage: erc20, event: ERC20Event);

// ERC20 Mixin
#[abi(embed_v0)]
impl ERC20MixinImpl = ERC20Component::ERC20MixinImpl<ContractState>;
impl ERC20InternalImpl = ERC20Component::InternalImpl<ContractState>;

#[storage]
struct Storage {
#[substorage(v0)]
erc20: ERC20Component::Storage
}

#[event]
#[derive(Drop, starknet::Event)]
enum Event {
#[flat]
ERC20Event: ERC20Component::Event
}

#[constructor]
fn constructor(
ref self: ContractState,
fixed_supply: u256,
recipient: ContractAddress,
) {
self.erc20.initializer("MyToken", "TKN");
self.erc20._mint(recipient, fixed_supply);
}
}

Conclusion

Hooks enhance Cairo contract development by streamlining the customization of token contracts. This reduces code duplication and makes your contracts easier to read, write, and maintain. Let us know what you think and happy coding!

Check out our roadmap to know what’s next or even better: help us build it with this list of good issues to tackle

Feel free to open an issue with any question, feedback, or feature request!

--

--