Working with ERC20 Tokens
The ERC20 standard on Starknet provides a uniform interface for fungible tokens. This ensures that any fungible token can be used in a predictable way across the ecosystem. This section explores how to create ERC20 tokens using OpenZeppelin Contracts for Cairo, which is an audited implementation of the standard.
Note: While the Openzeppelin components are audited, you should always test and ensure that your code cannot be exploited. Examples provided in this section are for educational purposes only and cannot be used in production.
First, we will build a basic ERC20 token with a fixed supply. This contract demonstrates the core structure for creating a token using OpenZeppelin's components.
The Basic ERC20 Contract
#[starknet::contract]
pub mod BasicERC20 {
use openzeppelin::token::erc20::{DefaultConfig, ERC20Component, 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, initial_supply: u256, recipient: ContractAddress) {
let name = "MyToken";
let symbol = "MTK";
self.erc20.initializer(name, symbol);
self.erc20.mint(recipient, initial_supply);
}
}
basic ERC20 token implementation using OpenZeppelin
Understanding the Implementation
This contract is built using OpenZeppelin's component system. It embeds the
ERC20Component, which contains all the core logic for an ERC20 token,
including functions for transfers, approvals, and balance tracking. To make
these functions directly available on the contract, we implement the
ERC20MixinImpl trait. This pattern avoids the need to write boilerplate code
for each function in the ERC20 interface.
When the contract is deployed, its constructor is called. The constructor first
initializes the token's metadata—its name and symbol—by calling the
initializer function on the ERC20 component. It then mints the entire initial
supply and assigns it to the address that deployed the contract. Since there are
no other functions to create new tokens, the total supply is fixed from the
moment of deployment.
The contract's storage is minimal, and only contains the state of the
ERC20Component. This includes mappings to track token balances and allowances,
as well as the token's name, symbol, and total supply, but is abstracted from
the perspective of the contract.
The contract we just implemented is rather simple: it is a fixed-supply token, with no additional features. But we can also use the OpenZeppelin components libraries to build more complex tokens!
The following examples show how to add new functionalities while maintaining compliance with the ERC20 standard.
Mintable and Burnable Token
This extension adds functions to mint new tokens and burn existing ones, allowing the token supply to change after deployment. This is useful for tokens whose supply needs to be adjusted based on protocol activity or governance.
#[starknet::contract]
pub mod MintableBurnableERC20 {
use openzeppelin::access::ownable::OwnableComponent;
use openzeppelin::token::erc20::{DefaultConfig, ERC20Component, ERC20HooksEmptyImpl};
use starknet::ContractAddress;
component!(path: OwnableComponent, storage: ownable, event: OwnableEvent);
component!(path: ERC20Component, storage: erc20, event: ERC20Event);
// Ownable Mixin
#[abi(embed_v0)]
impl OwnableMixinImpl = OwnableComponent::OwnableMixinImpl<ContractState>;
impl OwnableInternalImpl = OwnableComponent::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)]
erc20: ERC20Component::Storage,
}
#[event]
#[derive(Drop, starknet::Event)]
enum Event {
#[flat]
OwnableEvent: OwnableComponent::Event,
#[flat]
ERC20Event: ERC20Component::Event,
}
#[constructor]
fn constructor(ref self: ContractState, owner: ContractAddress) {
let name = "MintableBurnableToken";
let symbol = "MBT";
self.erc20.initializer(name, symbol);
self.ownable.initializer(owner);
}
#[external(v0)]
fn mint(ref self: ContractState, recipient: ContractAddress, amount: u256) {
// Only owner can mint new tokens
self.ownable.assert_only_owner();
self.erc20.mint(recipient, amount);
}
#[external(v0)]
fn burn(ref self: ContractState, amount: u256) {
// Any token holder can burn their own tokens
let caller = starknet::get_caller_address();
self.erc20.burn(caller, amount);
}
}
17-10: ERC20 with mint and burn capabilities
This contract introduces the OwnableComponent to manage access control. The
address that deploys the contract becomes its owner. The mint function is
restricted to the owner, who can create new tokens and assign them to any
address, thereby increasing the total supply.
The burn function allows any token holder to destroy their own tokens. This
action permanently removes the tokens from circulation and reduces the total
supply.
To make these functions exposed to the public, we simply mark them as
#[external] in the contract. They become part of the contract's entrypoint,
and anyone can call them.
Pausable Token with Access Control
This second extension introduces a more complex security model with role-based permissions and an emergency pause feature. This pattern is useful for protocols that need fine-grained control over operations and a way to halt activities during a crisis (e.g. a security incident).
#[starknet::contract]
pub mod PausableERC20 {
use openzeppelin::access::accesscontrol::AccessControlComponent;
use openzeppelin::introspection::src5::SRC5Component;
use openzeppelin::security::pausable::PausableComponent;
use openzeppelin::token::erc20::{DefaultConfig, ERC20Component};
use starknet::ContractAddress;
const PAUSER_ROLE: felt252 = selector!("PAUSER_ROLE");
const MINTER_ROLE: felt252 = selector!("MINTER_ROLE");
component!(path: AccessControlComponent, storage: accesscontrol, event: AccessControlEvent);
component!(path: SRC5Component, storage: src5, event: SRC5Event);
component!(path: PausableComponent, storage: pausable, event: PausableEvent);
component!(path: ERC20Component, storage: erc20, event: ERC20Event);
// AccessControl
#[abi(embed_v0)]
impl AccessControlImpl =
AccessControlComponent::AccessControlImpl<ContractState>;
impl AccessControlInternalImpl = AccessControlComponent::InternalImpl<ContractState>;
// SRC5
#[abi(embed_v0)]
impl SRC5Impl = SRC5Component::SRC5Impl<ContractState>;
// Pausable
#[abi(embed_v0)]
impl PausableImpl = PausableComponent::PausableImpl<ContractState>;
impl PausableInternalImpl = PausableComponent::InternalImpl<ContractState>;
// ERC20
#[abi(embed_v0)]
impl ERC20MixinImpl = ERC20Component::ERC20MixinImpl<ContractState>;
impl ERC20InternalImpl = ERC20Component::InternalImpl<ContractState>;
#[storage]
struct Storage {
#[substorage(v0)]
accesscontrol: AccessControlComponent::Storage,
#[substorage(v0)]
src5: SRC5Component::Storage,
#[substorage(v0)]
pausable: PausableComponent::Storage,
#[substorage(v0)]
erc20: ERC20Component::Storage,
}
#[event]
#[derive(Drop, starknet::Event)]
enum Event {
#[flat]
AccessControlEvent: AccessControlComponent::Event,
#[flat]
SRC5Event: SRC5Component::Event,
#[flat]
PausableEvent: PausableComponent::Event,
#[flat]
ERC20Event: ERC20Component::Event,
}
// ERC20 Hooks implementation
impl ERC20HooksImpl of ERC20Component::ERC20HooksTrait<ContractState> {
fn before_update(
ref self: ERC20Component::ComponentState<ContractState>,
from: ContractAddress,
recipient: ContractAddress,
amount: u256,
) {
let contract_state = self.get_contract();
// Check that the contract is not paused
contract_state.pausable.assert_not_paused();
}
}
#[constructor]
fn constructor(ref self: ContractState, admin: ContractAddress) {
let name = "PausableToken";
let symbol = "PST";
self.erc20.initializer(name, symbol);
// Grant admin role
self.accesscontrol.initializer();
self.accesscontrol._grant_role(AccessControlComponent::DEFAULT_ADMIN_ROLE, admin);
// Grant specific roles to admin
self.accesscontrol._grant_role(PAUSER_ROLE, admin);
self.accesscontrol._grant_role(MINTER_ROLE, admin);
}
#[external(v0)]
fn pause(ref self: ContractState) {
self.accesscontrol.assert_only_role(PAUSER_ROLE);
self.pausable.pause();
}
#[external(v0)]
fn unpause(ref self: ContractState) {
self.accesscontrol.assert_only_role(PAUSER_ROLE);
self.pausable.unpause();
}
#[external(v0)]
fn mint(ref self: ContractState, recipient: ContractAddress, amount: u256) {
self.accesscontrol.assert_only_role(MINTER_ROLE);
self.erc20.mint(recipient, amount);
}
}
ERC20 with pausable transfers and role-based access control
This implementation combines four components: ERC20Component for token
functions, AccessControlComponent for managing roles, PausableComponent for
the emergency stop mechanism, and SRC5Component for interface detection. The
contract defines two roles: PAUSER_ROLE, which can pause and unpause the
contract, and MINTER_ROLE, which can create new tokens.
Unlike a single owner, this role-based system allows for the separation of
administrative duties. The main administrator can grant the PAUSER_ROLE to a
security team and the MINTER_ROLE to a treasury manager.
The pause functionality is integrated into the token's transfer logic using a
hook system. The contract implements the ERC20HooksTrait, and its
before_update function is automatically called before any token transfer or
approval. This function checks if the contract is paused. If an address with the
PAUSER_ROLE has paused the contract, all transfers are blocked until it is
unpaused. This hook system is an elegant way of extending the base
functionalities of the ERC20 standard functions, without re-defining them.
At deployment, the constructor grants all roles to the deployer, who can then delegate these roles to other addresses as needed.
These extended implementations show how OpenZeppelin's components can be combined to build complex and secure contracts. By starting with standard, audited components, developers can add custom features without compromising on security or standards compliance.
For more advanced features and detailed documentation, refer to the OpenZeppelin Contracts for Cairo documentation.