Writing an ERC721 Contract with Cairo 2.0

Writing an ERC721 Contract with Cairo 2.0

In this article, we'll learn how to write an ERC721 contract using Cairo 2.0. We will use Reddio's ERC721 Demo as a sample for learning.

First, we need to define the ERC721 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 IERC721<TContractState> {
    fn get_name(self: @TContractState) -> felt252;
    fn get_symbol(self: @TContractState) -> felt252;
    fn token_uri(self: @TContractState, token_id: u256) -> felt252;
    fn balance_of(self: @TContractState, account: ContractAddress) -> u256;
    fn is_approved_for_all(
        self: @TContractState, owner: ContractAddress, operator: ContractAddress
    ) -> bool;

    fn owner_of(self: @TContractState, token_id: u256) -> ContractAddress;
    fn get_approved(self: @TContractState, token_id: u256) -> ContractAddress;

    fn set_approval_for_all(ref self: TContractState, operator: ContractAddress, approved: bool);
    fn approve(ref self: TContractState, to: ContractAddress, token_id: u256);
    fn transfer_from(
        ref self: TContractState, from: ContractAddress, to: ContractAddress, token_id: u256
    );
    fn mint(ref self: TContractState, recipient: ContractAddress, token_id: 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 ERC721 {}

Contracts need to import some global modules, so we need to import them:

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 ERC721 contracts, the following Storage structure will be easy to understand:

#[storage]
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>,
}

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 operator_approvals variable needs to use the format <(ContractAddress, ContractAddress), bool>. It represents a Map from a tuple to bool.

ERC721 needs to emit events, and in Cairo, events are defined as follows:

#[event]
#[derive(Drop, starknet::Event)]
enum Event {
    Approval: Approval,
    Transfer: Transfer,
    ApprovalForAll: ApprovalForAll,
}
#[derive(Drop, starknet::Event

)]
struct Approval {
    owner: ContractAddress,
    to: ContractAddress,
    token_id: u256
}
#[derive(Drop, starknet::Event)]
struct Transfer {
    from: ContractAddress,
    to: ContractAddress,
    token_id: u256
}
#[derive(Drop, starknet::Event)]
struct ApprovalForAll {
    owner: ContractAddress,
    operator: ContractAddress,
    approved: bool
}

All events must be declared in the Event enum. Here we have declared Transfer and Approval events. 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) {
    self.name.write(_name);
    self.symbol.write(_symbol);
}

Next, we implement the methods in ERC721. 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 ERC721 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 IERC721Impl of super::IERC721<ContractState> {}

Then put all the methods in the trait into the above block of code, for example:

#[abi(embed_v0)]
impl IERC721Impl of super::IERC721<ContractState> {
    ...

    fn get_name(self: @ContractState) -> felt252 {
        self.name.read()
    }

    ...
}

Here, #[abi(embed_v0)] indicates that all methods under this Impl block are external.

To implement private methods, just put the method in an Impl block without the #[abi(embed_v0)] tag. For example:

#[generate_trait]
impl StorageImpl of StorageTrait {
    fn _set_approval_for_all(
        ref self: ContractState,
        owner: ContractAddress,
        operator: ContractAddress,
        approved: bool
    ) {
        assert(owner != operator, 'ERC721: approve to caller');
        self.operator_approvals.write((owner, operator), approved);
        self.emit(Event::ApprovalForAll(ApprovalForAll { owner, operator, approved }));
    }
}

In this case, _set_approval_for_all is a private method, which can be called by other methods in the contract but cannot be called externally:

#[abi(embed_v0)]
impl IERC721Impl of super::IERC721<ContractState> {
    ...

    fn set_approval_for_all(
        ref self: ContractState, operator: ContractAddress, approved: bool
    ) {
        self._set_approval_for_all(get_caller_address(), operator, approved);
    }

    ...
}

#[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 ERC721, and readers can refer to Solidity's original ERC721 to write a complete Cairo ERC721. Finally, you can compare it with Reddio's ERC721 Demo.

After writing the ERC721, 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.