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);
}
}
Listing 17-8: A 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);
}
}
Listing 17-9: 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);
}
}
Listing 17-10: 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.