Writing an ERC20 Contract with Cairo 2.0

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.