How to write ERC721 contracts with Cairo 1.0
Introduction
In this guide, we will walk through how to write an ERC721 contract using Cairo 1.0. ERC721 is a standard interface for non-fungible tokens (NFTs) on the Ethereum blockchain. The Cairo programming language is a low-level language designed for use on the StarkNet platform, which is a Layer 2 scalability solution for Ethereum.
We will start with the contract declaration and defining contract variables. We will also cover how to define events and the constructor method. Additionally, we will go over regular contract methods, such as the balance_of
method, the approve
method, and the mint
method.
At the end of this guide, you will have a good understanding of the complete syntax and structure required to write an ERC721 contract using Cairo 1.0, and also the differences between Cairo and Solidity. You can find an example of a complete ERC721 contract code here.
Let’s get started!
Contract Declaration
To declare a contract, use the mod
keyword and the #[contract]
modifier:
#[contract]
mod erc721 {}
Define Contract Variables
To declare state variables in a contract, you can use the struct Storage
keyword. Unlike in Solidity, it is required that all state variables in the contract be defined here.
struct Storage {
name: felt252,
symbol: felt252,
owners: LegacyMap::<u256, ContractAddress>,
balances: LegacyMap::<ContractAddress, u256>,
token_approvals: LegacyMap::<u256, ContractAddress>,
// (owner, operator)
operator_approvals: LegacyMap::<(ContractAddress, ContractAddress), bool>,
}
In Solidity, name
and symbol
are typically defined using the string
type. However, in this case, they are defined using the felt252
type. Meanwhile, owners
is a mapping of token_id
to user_address
, which uses the LegacyMap
data structure. The same goes for balances
and token_approvals
. Finally, operator_approvals
is a mapping of a tuple
type to a boolean
, where the two ContractAddress
types represent owner
and operator
.
Define Events
To define an event with the following syntax, note the following:
- It must start with
fn
- The
#[event]
modifier must be used - An empty body enclosed in braces must be included
#[event]
fn event_name(args) {}
In the ERC721 contract, define the following three events:
#[event]
fn Approval(owner: ContractAddress, to: ContractAddress, token_id: u256) {}
#[event]
fn Transfer(from: ContractAddress, to: ContractAddress, token_id: u256) {}
#[event]
fn ApprovalForAll(owner: ContractAddress, operator: ContractAddress, approved: bool) {}
Constructor
The constructor in Cairo 1.0 is similar to that in Solidity, using constructor
as the method name. In the ERC721 contract, initialize name
and symbol
:
#[constructor]
fn constructor(_name: felt252, _symbol: felt252) {
name::write(_name);
symbol::write(_symbol);
}
Regular Contract Methods
Regular contract methods are similar to constructors, except for the method name. As an example, let‘s define the balance_of
method:
#[view]
fn balance_of(account: ContractAddress) -> u256 {
assert(!account.is_zero(), 'ERC721: address zero');
balances::read(account)
}
The #[view]
modifier indicates that this method is read-only, similar to the view
keyword in Solidity. The method takes an input parameter of type ContractAddress
and returns a parameter of type u256
. Before proceeding, it’s necessary to verify that the input parameter is not the zero address:
assert(!account.is_zero(), 'ERC721: address zero');
In this context, the assert
keyword is used for assertion, which differs from its usage in Solidity. Note that Cairo 1.0 does not have require
, only assert
. Additionally, it is important to keep the maximum length of any error messages to 31 characters or less to avoid compilation errors.
To check if an address is the zero address, we use the is_zero()
method of the address type. Import the relevant modules at the beginning of the contract if you want to use this method.
use zeroable::Zeroable;
use starknet::ContractAddressZeroable;
Once the condition of account
has been verified, we can retrieve the corresponding data from balances
and return it:
balances::read(account)
In Rust syntax, not adding a semicolon after the expression means that the value is returned directly as the return value.
Next, we will implement the approve
method:
#[internal]
fn _approve(to: ContractAddress, token_id: u256) {
token_approvals::write(token_id, to);
Approval(owner_of(token_id), to, token_id);
}
First, we implement the _approve
internal method, which is marked with the #[internal]
keyword to indicate that it is an internal method. However, this keyword can be omitted, as the default is an internal method. The method triggers the Approval
event. Unlike in Solidity, we do not need to explicitly use the emit
keyword to call the event. Simply using the event name is sufficient.
Next, we implement the approve
external method:
#[external]
fn approve(to: ContractAddress, token_id: u256) {
let owner = _owner_of(token_id);
assert(to.into() != owner.into(), 'Approval to current owner');
// || operator is not supported currently
// || is_approved_for_all(owner, get_caller_address())
assert(get_caller_address().into() == owner.into(), 'not token owner');
_approve(to, token_id);
}
In this method, note the following:
- You must explicitly mark the external method by adding the
#[external]
modifier. - Currently, you cannot directly compare the
ContractAddress
type, so you must convert it to thefelt
type using theinto()
method before comparison. - The
||
operator is not currently supported. - The
get_caller_address()
method returns the caller of the contract, which corresponds tomsg.sender
in Solidity.
If you need to use the into()
method of ContractAddress
, you must import the relevant modules at the beginning of the contract:
use starknet::ContractAddressIntoFelt252;
The get_caller_address()
method requires importing:
use starknet::get_caller_address;
Now let’s take a look at the mint
method:
#[internal]
fn _mint(to: ContractAddress, token_id: u256) {
assert(!to.is_zero(), 'ERC721: mint to 0');
assert(!_exists(token_id), 'ERC721: already minted');
balances::write(to, balances::read(to) + 1.into());
owners::write(token_id, to);
// This represents the 0 address
Transfer(contract_address_const::<0>(), to, token_id);
}
There are also a few new points to note:
- Numeric literals cannot be directly operated on with the
u256
type. We need to useinto()
to convert them. contract_address_const::<0>()
represents the 0 address, corresponding toaddress(0)
in Solidity.
To use array literal operations, import the following:
use traits::Into;
We have now covered some of the most commonly used syntax. You can refer to this link to see the complete ERC721 contract code.
Current limitations of ERC721 contracts
- Logical operators like && and || are not supported
safe
actions (If conract is the receiver of verification. It needs to implement specific interface)- supportsInterface
- token uri concatenation
- contract inheritance
- deployment (coming soon on testnet)
You can star our repository, we will closely update it alongside StarkNet rolls out 1.0 fully.
Call out StarkNet Beta Testers!
Reddio is building developer tools for StarkNet to help you accelerate the process to develop StarkNet applications. We are inviting all of StarkNet developers to join our beta testing group, try out brand-new features and tell us what you think.