Writing an ERC20 Contract with Cairo 2.0
In this article, we'll learn how to write an ERC20 contract using Cairo 2.0. We will use Starkware's official ERC20 Demo as a sample for learning.
First, we need to define the ERC20 interface, which is done using Cairo's trait
keyword, and we need to add the #[starknet::interface]
attribute to the trait
to indicate that this is a Cairo interface
. It serves a similar purpose to Solidity's interface
, which exposes the contract's callable characteristics and interfaces.
#[starknet::interface]
trait IERC20<TContractState> {
fn get_name(self: @TContractState) -> felt252;
fn get_symbol(self: @TContractState) -> felt252;
fn get_decimals(self: @TContractState) -> u8;
fn get_total_supply(self: @TContractState) -> u256;
fn balance_of(self: @TContractState, account: ContractAddress) -> u256;
fn allowance(self: @TContractState, owner: ContractAddress, spender: ContractAddress) -> u256;
fn transfer(ref self: TContractState, recipient: ContractAddress, amount: u256);
fn transfer_from(
ref self: TContractState, sender: ContractAddress, recipient: ContractAddress, amount: u256
);
fn approve(ref self: TContractState, spender: ContractAddress, amount: u256);
fn increase_allowance(ref self: TContractState, spender: ContractAddress, added_value: u256);
fn decrease_allowance(
ref self: TContractState, spender: ContractAddress, subtracted_value: u256
);
}
Notice that some methods use ref self: TContractState
, while others use self: @TContractState
. The difference is that methods using the former need to modify the contract's state, while those using the latter just read the contract's state, similar to Solidity's view
keyword.
TContractState
indicates that the method needs to interact with the contract's storage. If not needed, it's similar to Solidity's pure
methods.
After defining the interface
, we need to write the contract that implements the logic of the above interface
. We use the #[starknet::contract]
attribute to indicate that the following part is a smart contract. Then, we use the mod
keyword to define a contract. mod
is similar to Solidity's contract
, followed by the contract name.
#[starknet::contract]
mod erc_20 {}
Contracts need to import some global modules, so we need to import them:
use core::num::traits::Zero;
use starknet::get_caller_address;
use starknet::contract_address_const;
use starknet::ContractAddress;
For example, ContractAddress
here represents the address type in Cairo, similar to Solidity's address
. We need to import it first before we can use it in the smart contract code.
Every Cairo smart contract needs a Storage
structure to represent the variables stored in the contract. Even a very simple contract with no state variables needs to explicitly write out the Storage
part:
#[storage]
struct Storage {}
For developers familiar with ERC20 contracts, the following Storage
structure will be easy to understand:
#[storage]
struct Storage {
name: felt252,
symbol: felt252,
decimals: u8,
total_supply: u256,
balances: LegacyMap::<ContractAddress, u256>,
allowances: LegacyMap::<(ContractAddress, ContractAddress), u256>,
}
For example, in name: felt252
, name
is the variable name, and felt252
is the variable type. The final LegacyMap
type indicates a Map structure, similar to Solidity's mapping
, but Maps in Cairo cannot use nested structures, so the allowances
variable needs to use the format <(ContractAddress, ContractAddress), u256>
. It represents a Map from a tuple to u256
.
ERC20 needs to emit events, and in Cairo, events are defined as follows:
#[event]
#[derive(Drop, starknet::Event)]
enum Event {
Transfer: Transfer,
Approval: Approval,
}
#[derive(Drop, starknet::Event)]
struct Transfer {
from: ContractAddress,
to: ContractAddress,
value: u256,
}
#[derive(Drop, starknet::Event)]
struct Approval {
owner: ContractAddress,
spender: ContractAddress,
value: u256,
}
All events must be declared in the Event
enum. Here we have declared two events, Transfer
and Approval
. Then, we need to define the data types for each event, also using struct
. Note that they must be marked with #[derive(Drop, starknet::Event)]
.
The constructor of a Cairo smart contract is the same as in Solidity, needing to be named constructor
, but differently in Cairo, you need to add the #[constructor]
tag to explicitly indicate that it is a constructor:
#[constructor]
fn constructor(
ref self: ContractState,
name_: felt252,
symbol_: felt252,
decimals_: u8,
initial_supply: u256,
recipient: ContractAddress
) {
self.name.write(name_);
self.symbol.write(symbol_);
self.decimals.write(decimals_);
assert(!recipient.is_zero(), 'ERC20: mint to the 0 address');
self.total_supply.write(initial_supply);
self.balances.write(recipient, initial_supply);
self
.emit(
Event::Transfer(
Transfer {
from: contract_address_const::<0>(), to: recipient, value: initial_supply
}
)
);
}
In the above constructor, we notice the last line of code, which is how events are emitted in Cairo.
Next, we implement the methods in ERC20. In Cairo, methods are divided into external
and private
. external
indicates that the method can be called externally, private
indicates that the method is called by other methods within the contract.
We defined the ERC20 trait earlier. These methods should all be external
, and we need to implement the logic of these methods. To implement methods in a trait, use the following syntax:
#[external(v0)]
impl IERC20Impl of super::IERC20<ContractState> {}
Then put all the methods in the trait into the above block of code, for example:
#[external(v0)]
impl IERC20Impl of super::IERC20<ContractState> {
...
fn get_name(self: @ContractState) -> felt252 {
self.name.read()
}
...
}
Here, #[external(v0)]
indicates that all methods under this Impl block are external
. After Cairo 2.3.0, it is recommended to replace #[external(v0)]
with #[abi(embed_v0)]
.
To implement private
methods, just put the method in an Impl block without the #[external(v0)]
tag. For example:
#[generate_trait]
impl StorageImpl of StorageTrait {
fn transfer_helper(
ref self: ContractState,
sender: ContractAddress,
recipient: ContractAddress,
amount: u256
) {
assert(!sender.is_zero(), 'ERC20: transfer from 0');
assert(!recipient.is_zero(), 'ERC20: transfer to 0');
self.balances.write(sender, self.balances.read(sender) - amount);
self.balances.write(recipient, self.balances.read(recipient) + amount);
self.emit(Transfer { from: sender, to: recipient, value: amount });
}
}
In this case, transfer_helper
is a private
method, which can be called by other methods in the contract but cannot be called externally:
#[external(v0)]
impl IERC20Impl of super::IERC20<ContractState> {
...
fn transfer(ref self: ContractState, recipient: ContractAddress, amount: u256) {
let sender = get_caller_address();
self.transfer_helper(sender, recipient, amount);
}
...
}
#[generate_trait]
generates a trait for this Impl block implicitly by the compiler, so we don't have to write it manually.
We have now introduced the syntax needed to write ERC20, and readers can refer to Solidity's original ERC20 to write a complete Cairo ERC20. Finally, you can compare it with Starkware's official ERC20 Demo.
After writing the ERC20, you need to deploy it on a real blockchain network. The method of deployment can be referred to here.
Low latency and free Starknet node awaits!
For a limited time, Reddio is offering unrestricted access to its high-speed StarkNet Node, completely free of charge. This is an unparalleled opportunity to experience the fastest connection with the lowest delay. All you need to do is register an account on Reddio at https://dashboard.reddio.com/ and start exploring the limitless possibilities.
You can discover why Reddio claims the fastest connection by reading more here.