How to write ERC721 contracts with Cairo 1.0

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 the felt type using the into() method before comparison.
  • The || operator is not currently supported.
  • The get_caller_address() method returns the caller of the contract, which corresponds to msg.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 use into() to convert them.
  • contract_address_const::<0>() represents the 0 address, corresponding to address(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.

https://share.hsforms.com/1E88oQkqMSJifUV1CqR_WrQd30xn