Writing and deploying your first NFT on Starknet

Learn how to build your first Cairo smart contract!

Tagged

Darlington Nnam

Oct 3, 2024

Quick summary

We’re on a mission to bring developers into the Starknet ecosystem. To achieve this, we’re working on a content series that will educate and guide you through the basics of building on the network.

Previously, we explained what an ERC20 token is and how to create and deploy an ERC20 token on Starknet. This week, we’ll focus on ERC721 tokens, also known as NFTs.

By the end of this article, you’ll understand what an ERC721 token is and how to create one from scratch.

WTF are NFTs?

NFTs (Non-fungible tokens) are unique digital assets that live on a blockchain, like Starknet.

Popularly known as NFTs, these tokens differ from their counterparts (Fungible tokens) because they cannot be exchanged or traded at equivalence for other tokens.

Unlike an ERC20 token which is fungible, meaning they are replaceable by the same token, similar to how money works. NFTs are non-fungible, meaning they cannot be replaced, as each has a unique identifier and metadata that distinguishes them from other tokens.

The ERC721 Standard

The ERC721 Standard was introduced by William Entriken, Dieter Shirley, Jacob Evans, and Nastassia Sachs and was inspired by the ERC20 Standard, containing ERC20-like methods such as:

  • name: This function defines the token’s name.
  • symbol: This function defines the token’s symbol.
  • balanceOf: This function returns number of NFTs owned by an address.

The ERC721 standard also introduces some new methods:

  • ownerOf: This function returns the address of the owner of a token.
  • supportsInterface: This function queries if a contract implements an interface.
  • transferFrom: Transfers token ownership from one account to another.
  • safeTransferFrom: Carries out safe transfer of token ownership from one account to another. A safe transfer means that it checks whether the receiver is valid. It can also accept additional data sent to the receiver.
  • approve: This function grants or approves another entity the permission to transfer tokens on the owner’s behalf.
  • setApprovalForAll: This function enables or disables approval for an operator to manage all owner’s asset.
  • getApproved: This function gets the approved address for a particular token ID.
  • isApprovedForAll: This function queries if an address is an authorized operator for another address.

Setting up Scarb

As always, we would be using the Scarb package manager for development. If you still don’t have Scarb installed at this point, refer to the documentation here. To get started, we will need to initialize a new project. Let’s call our project “argentNFT”.

To do this run:

Scarb new argentNFT

Creating a new file

We need to create a new file called ERC721.cairo in our src folder. This is where we’re going to be writing our contract code.

Imports

Inside our new file, we’ll begin with the #[starknet::contract] attribute, which specifies that our file contains code for a Starknet contract.

After we’ve done that, we’ll create a new module “ERC721” and begin by importing all the necessary library functions we’ll need for our contract.

#[starknet::contract]
mod ERC721Contract {
    use starknet::ContractAddress;
    use starknet::get_caller_address;
    use zeroable::Zeroable;
    use starknet::contract_address_to_felt252;
    use traits::Into;
    use traits::TryInto;
    use option::OptionTrait;
}

Storage variables

Next up, we’ll be defining all our storage variables. With the new Cairo syntax, storage structs must be specified using the `#[storage]` attribute:

   #[storage]
    struct Storage {
        name: felt252,
        symbol: felt252,
        owners: LegacyMap::<u256, ContractAddress>,
        balances: LegacyMap::<ContractAddress, u256>,
        token_approvals: LegacyMap::<u256, ContractAddress>,
        operator_approvals: LegacyMap::<(ContractAddress, ContractAddress), bool>,
        token_uri: LegacyMap<u256, felt252>,
    }

Events

Our contracts will definitely need to emit certain events such as `Approval`, `Transfer`, and `ApprovalForAll`. To do this we’ll need first to specify all the events to be emitted in an enum called `Event`, with custom data types.

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

Finally, we’ll create these structs, with the members being the variables to be emitted:

   #[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
    }

Writing the contract

Constructor

For our ERC721 token, we need to initialize certain variables on deployment, such as the name, and symbol. To do this, our contract must implement a constructor:

    #[constructor]
    fn constructor(ref self: ContractState, _name: felt252, _symbol: felt252) {
        self.name.write(_name);
        self.symbol.write(_symbol);
    }

As you can see from the snippet above, the constructor function takes in a reference to a `self` parameter which points to the contract’s storage, a `_name` parameter representing the name of our NFT, and a `_symbol` parameter, which is finally initialized within the constructor body.

Contract Implementation

With the new Cairo syntax, all public functions are defined within an implementation block, and required to subscribe to a certain Interface usually specified at the top of the contract.

To keep this contract simple, rather than manually implement an interface for our NFT, we are simply going to let the compiler automatically generate the interface trait by using the `generate_trait` attribute.

We are also going to be specifying the `external[v0]` attribute to inform the compiler that the functions contained within this implementation block are public/external functions.

#[external(v0)]
#[generate_trait]
impl IERC721Impl of IERC721Trait {
   ////////////////////////////////
   // get_name function returns token name
   ////////////////////////////////
   fn get_name(self: @ContractState) -> felt252 {
       self.name.read()
   }

   ////////////////////////////////
   // get_symbol function returns token symbol
   ////////////////////////////////
   fn get_symbol(self: @ContractState) -> felt252 {
       self.symbol.read()
   }

   ////////////////////////////////
   // token_uri returns the token uri
   ////////////////////////////////
   fn get_token_uri(self: @ContractState, token_id: u256) -> felt252 {
       assert(self._exists(token_id), 'ERC721: invalid token ID');
       self.token_uri.read(token_id)
   }

   ////////////////////////////////
   // balance_of function returns token balance
   ////////////////////////////////
   fn balance_of(self: @ContractState, account: ContractAddress) -> u256 {
       assert(account.is_non_zero(), 'ERC721: address zero');
       self.balances.read(account)
   }

   ////////////////////////////////
   // owner_of function returns owner of token_id
   ////////////////////////////////
   fn owner_of(self: @ContractState, token_id: u256) -> ContractAddress {
       let owner = self.owners.read(token_id);
       assert(owner.is_non_zero(), 'ERC721: invalid token ID');
       owner
   }

   ////////////////////////////////
   // get_approved function returns approved address for a token
   ////////////////////////////////
   fn get_approved(self: @ContractState, token_id: u256) -> ContractAddress {
       assert(self._exists(token_id), 'ERC721: invalid token ID');
       self.token_approvals.read(token_id)
   }

   ////////////////////////////////
   // is_approved_for_all function returns approved operator for a token
   ////////////////////////////////
   fn is_approved_for_all(self: @ContractState, owner: ContractAddress, operator: ContractAddress) -> bool {
        self.operator_approvals.read((owner, operator))
   }

   ////////////////////////////////
   // approve function approves an address to spend a token
  ////////////////////////////////
  fn approve(ref self: ContractState, to: ContractAddress, token_id: u256) {
      let owner = self.owner_of(token_id);
      assert(to != owner, 'Approval to current owner');
      assert(
          get_caller_address() == owner || self.is_approved_for_all(owner, get_caller_address()), 
          'Not token owner'
     );
      self.token_approvals.write(token_id, to);
      self.emit(
         Approval{ owner: self.owner_of(token_id), to: to, token_id: token_id }
      );
   }

   ////////////////////////////////
   // set_approval_for_all function approves an operator to spend all tokens 
   ////////////////////////////////
   fn set_approval_for_all(ref self: ContractState, operator: ContractAddress, approved: bool) {
       let owner = get_caller_address();
       assert(owner != operator, 'ERC721: approve to caller');
       self.operator_approvals.write((owner, operator), approved);
       self.emit(
            ApprovalForAll{ owner: owner, operator: operator, approved: approved }
       );
   }

  ////////////////////////////////
  // transfer_from function is used to transfer a token
  ////////////////////////////////
 fn transfer_from(ref self: ContractState, from: ContractAddress, to: ContractAddress, token_id: u256) {
    assert(
           self._is_approved_or_owner(get_caller_address(), token_id), 
           'neither owner nor approved'
    );
    self._transfer(from, to, token_id);
 }
}

PS: To keep things a bit neater whilst making this tutorial as clear as possible, I’ve included a description of what each function does within comment blocks in the code.

Finally, if you take a good look at the code above, you’ll notice we delegated the logic for certain functions to a helper function. We’ll also need to write out these helper functions, within an implementation block, but since they are not public functions we are going to exclude the `external[v0]` attribute.

#[generate_trait]
impl ERC721HelperImpl of ERC721HelperTrait {
   ////////////////////////////////
   // internal function to check if a token exists
   ////////////////////////////////
   fn _exists(self: @ContractState, token_id: u256) -> bool {
      // check that owner of token is not zero
      self.owner_of(token_id).is_non_zero()
   }

   ////////////////////////////////
   // _is_approved_or_owner checks if an address is an approved spender or owner
   ////////////////////////////////
   fn _is_approved_or_owner(self: @ContractState, spender: ContractAddress, token_id: u256) -> bool {
       let owner = self.owners.read(token_id);
       spender == owner
          || self.is_approved_for_all(owner, spender) 
          || self.get_approved(token_id) == spender
    }

   ////////////////////////////////
   // internal function that sets the token uri
   ////////////////////////////////
   fn _set_token_uri(ref self: ContractState, token_id: u256, token_uri: felt252) {
        assert(self._exists(token_id), 'ERC721: invalid token ID');
        self.token_uri.write(token_id, token_uri)
   }

   ////////////////////////////////
   // internal function that performs the transfer logic
   ////////////////////////////////
   fn _transfer(ref self: ContractState, from: ContractAddress, to: ContractAddress, token_id: u256) {
       // check that from address is equal to owner of token
       assert(from == self.owner_of(token_id), 'ERC721: Caller is not owner');
       // check that to address is not zero
       assert(to.is_non_zero(), 'ERC721: transfer to 0 address');

       // remove previously made approvals
       self.token_approvals.write(token_id, Zeroable::zero());

       // increase balance of to address, decrease balance of from address
       self.balances.write(from, self.balances.read(from) - 1.into());
       self.balances.write(to, self.balances.read(to) + 1.into());

       // update token_id owner
       self.owners.write(token_id, to);

       // emit the Transfer event
       self.emit(
           Transfer{ from: from, to: to, token_id: token_id }
       );
    }

    ////////////////////////////////
    // _mint function mints a new token to the to address
    ////////////////////////////////
    fn _mint(ref self: ContractState, to: ContractAddress, token_id: u256) {
        assert(to.is_non_zero(), 'TO_IS_ZERO_ADDRESS');

        // Ensures token_id is unique
        assert(!self.owner_of(token_id).is_non_zero(), 'ERC721: Token already minted');

        // Increase receiver balance
        let receiver_balance = self.balances.read(to);
        self.balances.write(to, receiver_balance + 1.into());

        // Update token_id owner
        self.owners.write(token_id, to);

        // emit Transfer event
        self.emit(
            Transfer{ from: Zeroable::zero(), to: to, token_id: token_id }
        );
     }

     ////////////////////////////////
     // _burn function burns token from owner's account
     ////////////////////////////////
     fn _burn(ref self: ContractState, token_id: u256) {
        let owner = self.owner_of(token_id);

        // Clear approvals
        self.token_approvals.write(token_id, Zeroable::zero());

        // Decrease owner balance
        let owner_balance = self.balances.read(owner);
        self.balances.write(owner, owner_balance - 1.into());

        // Delete owner
        self.owners.write(token_id, Zeroable::zero());
        // emit the Transfer event
        self.emit(
            Transfer{ from: owner, to: Zeroable::zero(), token_id: token_id }
        );
     }
   }

We also use the `generate_trait` attribute, to ensure the compiler handles the generation of the interface trait.

Conclusion

Having gotten to this point, congratulations! You just completed your first ERC721 Starknet contract. You could find the full source code in the repo here.

If you have any questions as regards this, reach out to me @0xdarlington, I’d love to help you build on StarkNet with Argent X.

For more developer resources, follow us across our socials:

Twitter — @argentHq

Engineering Twitter — @argentDeveloper

LinkedIn — @argentHq

Youtube — @argentHQ