How to write ERC20 contracts with Cairo 1.0
StarkWare’s official repo provides a demo for ERC20 code, which we will use to learn how to write Cairo 1.0 contracts in this article.
Contract Definition
First, at the beginning of the contract, #[contract]
is used to indicate that the following content is a smart contract, followed by the mod
to define a contract, similar to the contract
in Solidity, followed by the contract name.
#[contract]
mod ERC20 {}
Dependent Modules
use zeroable::Zeroable;
use starknet::get_caller_address;
use starknet::contract_address_const;
use starknet::ContractAddress;
use starknet::ContractAddressZeroable;
This part imports some modules that are required in the contract. For example, use starknet::get_caller_address
is used to use the get_caller_address()
method, similar to the msg.sender
keyword in Solidity. However, in Cairo 1.0, you need to explicitly import these modules. We will introduce more of these modules when we use them later.
Contract State Variables
struct Storage {
name: felt252,
symbol: felt252,
decimals: u8,
total_supply: u256,
balances: LegacyMap::<ContractAddress, u256>,
allowances: LegacyMap::<(ContractAddress, ContractAddress), u256>,
}
Unlike Solidity, state variables in Cairo 1.0 need to be specifically defined in a struct named Storage
. Here, we can see the variable types used are:
- felt252 => string
- u8 => uint8
- u256 => uint256
- ContractAddress => address
- LegacyMap => mapping
The first part is the type in Cairo 1.0, followed by the type corresponding to uint256. ContractAddress
here represents the address type, so you need to import the corresponding module use starknet::ContractAddress
to be able to use it. u256
type is currently not fully supported and is actually composed of two u128
blocks in the underlying implementation, which we will see later.
The last variable allowances
uses a ContractAddress
tuple as the key and u256
as the value, similar to:
mapping(address => mapping(address => uint256))
Define Event
#[event]
fn Transfer(from: ContractAddress, to: ContractAddress, value: u256) {}
#[event]
fn Approval(owner: ContractAddress, spender: ContractAddress, value: u256) {}
To define an event in Cairo 1.0, use the #[event]
keyword to indicate that the following content is an event. Define it with fn
, usually with the first letter capitalized and finally remember to add an empty bracket.
Define Function Constructor
#[constructor]
fn constructor(
name_: felt252,
symbol_: felt252,
decimals_: u8,
initial_supply: u256,
recipient: ContractAddress
) {
name::write(name_);
symbol::write(symbol_);
decimals::write(decimals_);
assert(!recipient.is_zero(), 'ERC20: mint to the 0 address');
total_supply::write(initial_supply);
balances::write(recipient, initial_supply);
Transfer(contract_address_const::<0>(), recipient, initial_supply);
}
To define a constructor, use the #[constructor]
keyword to indicate that the following content is a constructor and use fn
to define a function.
We can see that the assert
keyword is used for verification in the function. Unlike Solidity, there is currently only assert
in Cairo 1.0 and no require
.
The .is_zero()
method here is used to determine whether the address type recipient
is a 0 address, similar to recipient == address(0)
in Solidity. To use .is_zero()
method, you need to import the following module:
use zeroable::Zeroable;
use starknet::ContractAddressZeroable;
Finally, trigger the Transfer
event. In Cairo 1.0, triggering an event does not require the emit
keyword. You can call the event just like calling a function. Here, contract_address_const::<0>()
is similar to address(0)
in Solidity and needs to import use starknet::contract_address_const
to use it.
Define Normal Function
Functions in Cairo 1.0 are similar to Solidity, and the visibility of functions is also distinguished. Common keywords include:
#[external]
#[view]
If no visibility keyword is added, the function is internal
by default. After testing, these two keywords are only used as identifiers at this stage, and have no practical meaning at the compilation level, and need to be updated later.
Let’s take a look at a common transfer
function:
#[external]
fn transfer(recipient: ContractAddress, amount: u256) {
let sender = get_caller_address();
transfer_helper(sender, recipient, amount);
}
fn transfer_helper(sender: ContractAddress, recipient: ContractAddress, amount: u256) {
assert(!sender.is_zero(), 'ERC20: transfer from 0');
assert(!recipient.is_zero(), 'ERC20: transfer to 0');
balances::write(sender, balances::read(sender) - amount);
balances::write(recipient, balances::read(recipient) + amount);
Transfer(sender, recipient, amount);
}
The user transfers by calling transfer
and passing in the recipient’s address and transfer amount. The function first uses get_caller_address()
to get the user’s address, which is similar to msg.sender
in Solidity. Remember to import the module use starknet::get_caller_address
.
Then call the internal function transfer_helper
, which first performs parameter verification, then changes the account books of both parties, and finally triggers an event.
Let’s take a look at a function that is not easy to understand, spend_allowance
:
fn spend_allowance(owner: ContractAddress, spender: ContractAddress, amount: u256) {
let current_allowance = allowances::read((owner, spender));
let ONES_MASK = 0xffffffffffffffffffffffffffffffff_u128;
let is_unlimited_allowance =
current_allowance.low == ONES_MASK & current_allowance.high == ONES_MASK;
if !is_unlimited_allowance {
approve_helper(owner, spender, current_allowance - amount);
}
}
First, read the amount authorized by owner
to spender
through allowances
, then define a variable ONES_MASK
of type u128
, and then:
let is_unlimited_allowance =
current_allowance.low == ONES_MASK & current_allowance.high == ONES_MASK;
to determine whether the authorization is unlimited, which is equivalent to type(uint256).max
in Solidity. However, why is it judged this way? Because u256
cannot be fully implemented in Cairo 1.0 yet, u256
is actually concatenated by two u128
blocks at the underlying level. Therefore, u256
is divided into high
(high order) and low
(low order) parts at the bit level, and compared with the maximum value of u128
to finally determine whether it is an unlimited authorization.
Finally:
if !is_unlimited_allowance {
approve_helper(owner, spender, current_allowance - amount);
}
If it is not an unlimited authorization, adjust the authorized amount. If it is unlimited, skip it. This is also a way to save gas, because if the authorized amount is the maximum value of u256
, theoretically, this amount cannot be spent.
Here we have learned the common syntax of Cairo ERC20, and you can refer to this link for the remaining parts.
Contract Compilation
Finally, let’s compile and deploy the contract. Starting from Starknet v0.11.0, Cairo 1.0 contracts can be deployed.
Before you start, Starknet development tools need to be installed:
- Starkli - A CLI tool for interacting with Starknet.
- Scarb - Cairo’s package manager that compiles code to Sierra, a mid-level language between Cairo and CASM.
Install Scarb
curl --proto '=https' --tlsv1.2 -sSf https://docs.swmansion.com/scarb/install.sh | sh
For Windows, follow manual setup in the Scarb documentation.
Restart the terminal and check if Scarb is installed correctly:
scarb --version
Compile & Build
scarb build
Then you will get the target
directory, which contains the compiled sierra files.
Install Starkli
If you're on Linux/macOS/WSL/Android, you can install stakrliup by running the following command:
curl https://get.starkli.sh | sh
You might need to restart your shell session for the starkliup command to become available. Once it's available, run the starkliup command:
starkliup
Running the commands installs starkli for you, and upgrades it to the latest release if it's already installed.
starkliup
detects your device's platform and automatically downloads the right prebuilt binary. It also sets up shell completions. You might need to restart your shell session for the completions to start working.
Account Creation
Generate keystore:
starkli signer keystore new /path/to/keystore
then a keystore file will be created at /path/to/keystore
.
You can then use it via the --keystore <PATH>
option for commands expecting a signer.
Alternatively, you can set the STARKNET_KEYSTORE
environment variable to make command invocations easier:
export STARKNET_KEYSTORE="/path/to/keystore"
Before creating an account, you must first decide on the variant to use. As of this writing, the only supported variant is oz
, the OpenZeppelin account contract.
All variants come with an init subcommand that creates an account file ready to be deployed. For example, to create an oz
account:
starkli account oz init /path/to/account
Account deployment
Once you have an account file, you can deploy the account contract with the starkli account deploy
command. This command sends a DEPLOY_ACCOUNT
transaction, which requires the account to be funded with some ETH
for paying for the transaction fee.
You can get some test funds here.
For example, to deploy the account we just created:
starkli account deploy /path/to/account
When run, the command shows the address where the contract will be deployed on, and instructs the user to fund the account before proceeding. Here's an example command output:
The estimated account deployment fee is 0.000011483579723913 ETH. However, to avoid failure, fund at least: 0.000017225369585869 ETH to the following address: 0x01cf4d57ba01109f018dec3ea079a38fc08b789e03de4df937ddb9e8a0ff853a Press [ENTER] once you've funded the address.
Once the account deployment transaction is confirmed, the account file will be update to reflect the deployment status. It can then be used for commands where an account is expected. You can pass the account either with the --account
parameter, or with the STARKNET_ACCOUNT
environment variable.
Declaring classes
In Starknet, all deployed contracts are instances of certain declared classes. Therefore, the first step of deploying a contract is declaring a class, if it hasn't been declared already.
With Starkli, this is done with the starkli declare
command.
Before declare, you should set environment variables for Starkli:
export STARKNET_RPC="https://starknet-goerli.reddio.com"
export STARKNET_ACCOUNT=/path/to/keystore
export STARKNET_KEYSTORE=/path/to/account
Notes: To make the deployment easier, you can declare STARKNET_RPC to be Reddio's RPC node for Starknet testnet.
After scarb build
, you will get the *.json
file in the target
directory, which we'll use to declare the contract class:
starkli declare *.json
such as:
starkli declare target/dev/reddio_cairo_ERC20.contract_class.json
If you encounter the following error:
Error: No such file or directory (os error 2)
The chance is you are not defining environment variables correctly, make sure you use absolute path for the file path.
If you get an error like this:
Not declaring class as it's already declared.
This is because the class has been declared by someone else before and a class cannot be declared twice in Starknet. You can just deploy it using the current declared class or write a new unique contract.
Once the declaration is successful, Starkli displays the class hash declared. The class hash is needed for deploying contracts as below,
Enter keystore password:
Sierra compiler version not specified. Attempting to automatically decide version to use...
Network detected: goerli-1. Using the default compiler version for this network: 2.1.0. Use the --compiler-version flag to choose a different version.
Declaring Cairo 1 class: 0x004d09cf179b98c6551ac5114c10f21674b8955fdd6104dbc7c79b75177da690
Compiling Sierra class to CASM with compiler version 2.1.0...
CASM class hash: 0x04e224312aea85f8a343cd5e7d6ae7a063a6151cf809a8c9dbc3bc022b0e83bb
Contract declaration transaction: 0x07d6d3559e939f282b59048473effe52321bd204faff72d56dd4286bef934046
Class hash declared:
0x004d09cf179b98c6551ac5114c10f21674b8955fdd6104dbc7c79b75177da690
Deploying the ERC20 contract
Once you obtain a class hash by declaring a class, it's ready to deploy instances of the class.
With Starkli, this is done with the starkli deploy
command.
To deploy a contract with class hash <CLAS_HASH>
, simply run:
starkli deploy <CLASS_HASH> <CONSTRUCTOR_INPUTS>
where <
CONSTRUCTOR_INPUTS>
is the list of constructor arguments, if any.
Note that string parameters should be cast to hexadecimal in CLI. So we need to convert a short string to a felt252 format. We can use the to-cairo-string
command for this:
starkli to-cairo-string <STRING>
In this case, we'll use the string "reddiotoken" as the name and the symbol:
starkli to-cairo-string reddiotoken
The output:
0x72656464696f746f6b656e
We will define decimals as 0x12, which is 18, to align with ETH and ERC20 convention.
Now deploy using a class hash and constructor input:
starkli deploy 0x004d09cf179b98c6551ac5114c10f21674b8955fdd6104dbc7c79b75177da690 0x72656464696f746f6b656e 0x72656464696f746f6b656e 0x12
The output should appear similar to:
Deploying class 0x004d09cf179b98c6551ac5114c10f21674b8955fdd6104dbc7c79b75177da690 with salt 0x046c071e77a7d09a3e2a9684ab7c59ff8bccc6cec23ede033cee82f75e50f2cc...
The contract will be deployed at address 0x007dda0853091a7f359b17eeb5ea234c9a626da5f389837c4cbeba9ff88e5bb6
Contract deployment transaction: 0x0264dd8f0bfdf373dcd78932676cd4ca987e33313521100c6ef8e286048c2b4e
Contract deployed:
0x007dda0853091a7f359b17eeb5ea234c9a626da5f389837c4cbeba9ff88e5bb6
NOTE: The deployed address received will differ for every user. Retain this address, as it will replace instances in subsequent TypeScript files to match the specific contract address.
Well done! The Cairo ERC20 smart contract has been deployed successfully on Starknet. You can find the deployed token at Starkscan.
Interacting with the Starknet Contract
Starkli enables interaction with smart contracts via two primary methods: call
for read-only functions and invoke
for write functions that modify the state.
Invoking contracts
With Starkli, this is done with the starkli invoke
command.
The basic format of a starkli invoke
command is the following:
starkli invoke <ADDRESS> <SELECTOR> <ARGS>
For example, to mint 1,000,000 tokens for ERC20 contract to 0x4e1f5590b0fc94f4ba6b563937ec652a9cbfc7b7372433fb4f1eaf2464a3de, you can run:
starkli invoke 0x007dda0853091a7f359b17eeb5ea234c9a626da5f389837c4cbeba9ff88e5bb6 mint 0x4e1f5590b0fc94f4ba6b563937ec652a9cbfc7b7372433fb4f1eaf2464a3de u256:100000
Calling a Read Function
The call
command enables you to query a smart contract function without sending a transaction. For instance, to find out who the current name of the contract is, you can use the get_owner
function, which requires no arguments.
starkli call 0x007dda0853091a7f359b17eeb5ea234c9a626da5f389837c4cbeba9ff88e5bb6 get_name
Replace <CONTRACT_ADDRESS>
with the address of your contract. The command will return the ERC20 token name, which was initially set during the contract’s deployment:
[
"0x00000000000000000000000000000000000000000072656464696f746f6b656e"
]
Similarly, to query the total supply after you invoke the mint function,
starkli call 0x007dda0853091a7f359b17eeb5ea234c9a626da5f389837c4cbeba9ff88e5bb6 get_total_supply
Here's the returned result,
[
"0x00000000000000000000000000000000000000000000000000000000000f4240",
"0x0000000000000000000000000000000000000000000000000000000000000000"
]
Summary
In this article, we mainly focused on the development and deployment of the ERC20 Cairo 1.0 contract. Cairo 1.0 is currently in a fast iteration phase, and the syntax may be adjusted in the future. You can keep track of the latest syntax information by following Starknet and Redidio’s updates. You can star our repository, and we will update it closely as StarkNet rolls out 1.0 fully, or join our Discord if you have any questions or want to contribute.