An ERC721 Non-Fungible-Token (NFT) Smart Contract

An ERC721 Non-Fungible-Token (NFT) Smart Contract

ERC721 is the standard interface for non-fungible tokens, also known as NFTs. Having a standard is important as it promotes interoperability, which means your NFT is valid on every application that uses the standard.

Through this article, I will walk you through all the functions in an ERC721 contract and at the end we will create an ERC721 token and an NFTGatedEvent contract with the knowledge we have gathered.

Ready to dive in? Let’s go!

Let’s begin with a detailed explanation of all the functions in the interface of a standard ERC721 Contract.

An interface defines a set of standard functions that an ERC721-compliant contract must implement. This standard defines the standard and functionalities that an NFT (Non-Fungible Token) contract must adhere to for interoperability with other contracts and platforms. This means that if you want to write an NFT Contract (ERC721) and you want to implement certain features, these are the functions that you must implement for your logic:


interface IERC721 is IERC165 {
    // Emitted when `tokenId` token is transferred.
    event Transfer(address indexed from, address indexed to, uint256 indexed tokenId);

    // Emitted when `owner` enables `approved` to manage the `tokenId` token.
    event Approval(address indexed owner, address indexed approved, uint256 indexed tokenId);

    // Emitted when `owner` enables or disables approved `operator` to manage all of its assets.
    event ApprovalForAll(address indexed owner, address indexed operator, bool approved);

    // Returns the number of tokens in `owner`'s account.
    function balanceOf(address owner) external view returns (uint256 balance);

    //  Returns the owner of the `tokenId` token.
    function ownerOf(uint256 tokenId) external view returns (address owner);

    // Transfers token.
    function transferFrom(address from, address to, uint256 tokenId) external;

    // Safely transfers token.
    function safeTransferFrom(address from, address to, uint256 tokenId) external;

    // Gives permission to transfer token to another account..
    function approve(address to, uint256 tokenId) external;

    // Approve or remove address as an operator.
    function setApprovalForAll(address operator, bool approved) external;

    // Returns the account approved for `tokenId` of token..
    function getApproved(uint256 tokenId) external view returns (address operator);

    // Returns if the `operator` is allowed to manage all of the assets of `owner`
    function isApprovedForAll(address owner, address operator) external view returns (bool);
}

Why are we inheriting IERC165?

The above code sample is the IERC721 interface contract that follows the OpenZeppelin standard implementation of an interface contract for ERC721 contracts. A standard ERC721 contract uses the IERC721 interface contract to inherit the IERC165 contract, this is to ensure that the ERC721 contract complies also with the ERC165 standard. This inheritance serves the purpose of signalling the support for interface detection..

Purpose of IERC165

The IERC165 interface provides a standard way to publish and detect what interfaces a contract implements. Its primary function is to allow a contract to advertise which interfaces it supports. This is particularly useful for contracts that may implement multiple interfaces, as it provides a consistent way to check for support of a specific interface. You can read more about it here.

The supportsInterface function allows a contract to signal whether it supports a particular interface by returning a boolean value. It takes an interface ID as a parameter and returns true if the contract supports the interface and false if it does not.

Deep dive into ERC721

Now that we know the functions in a standard ERC721, let's look at how these functions are implemented in the standard ERC721 library implementation and what each of these functions does and represents.

Transfer, Approval and ApprovalForAll events have been explained well by the comment in the interface above, so I will be focusing on the functions.

balanceOf

  function balanceOf(address owner) external view returns (uint256 balance);

This function takes in an address and returns the number of NFTs (tokens) that the specific address (owner) holds within a particular ERC721 collection.
It verifies that the address is not an address zero. An address zero is represented by a string of all zeros: 0x0000000000000000000000000000000000000000 which simply means a non-existent address.

ownerOf

    function ownerOf(uint256 tokenId) external view returns (address owner);

This function helps to determine the current owner of a specific NFT that is identified by its unique token ID (tokenId) within the ERC721 collection.
This function returns the address that currently owns an NFT.

transferFrom

    function transferFrom(address from, address to, uint256 tokenId) external;

This function takes in the address of the current owner of the NFT(from), the receiver(to), and the tokenId of the NFT you are transferring and then transfers the ownership of that NFT (tokenId).

safeTransferFrom

function safeTransferFrom(address from, address to, uint256 tokenId) external;

This function offers a safer and more robust approach to transferring NFTs between accounts compared to the standard transferFrom function.
It prioritizes security measures to prevent unintended consequences and potential token loss.

Difference betweentransferFromandsafeTransferFrom

transferFromsafeTransferFrom
Designed for basic transfers where the caller has already verified the recipient's ability to handle NFTs.It does an extra check that checks if the recipient's address is a contract.
Only performs a basic check to ensure the recipient address isn't the zero address (address(0)) to prevent sending NFTs to invalid accounts.If the recipient is a contract, it calls the onERC721Received function on the recipient contract to ensure it can properly receive and handle ERC721 tokens. This prevents accidental locking of NFTs in incompatible contracts.

When to use which of the functions

transferFromsafeTransferFrom
UsetransferFrom when transferring NFTs to trusted addresses that you know can handle ERC721 tokens appropriately.UsesafeTransferFrom when the recipient is an unknown or third-party contract. Also when you want to guarantee the safety of the NFT transfer and avoid potential locking issues.

approve

// interface 
function approve(address to, uint256 tokenId) external;

// Openzepplin implementation
 function _approve(address to, uint256 tokenId, address auth, bool emitEvent) internal virtual {
        // Avoid reading the owner unless necessary
        if (emitEvent || auth != address(0)) {
            address owner = _requireOwned(tokenId);

            // We do not use _isAuthorized because single-token approvals should not be able to call approve
            if (auth != address(0) && owner != auth && !isApprovedForAll(owner, auth)) {
                revert ERC721InvalidApprover(auth);
            }

            if (emitEvent) {
                emit Approval(owner, to, tokenId);
            }
        }

        _tokenApprovals[tokenId] = to;
    }

The above two functions work together in the ERC721 standard for authorizing another address to transfer a specific NFT.

approve

The public function approve serves as the entry point for approving another address (recipient) to transfer a particular NFT (identified by its tokenId).

_approve

The internal function _approve handles the core logic of authorization. It's called by approve and potentially other functions within the contract.

  • It takes in two additional parameters:

    • auth: The address that is being authorized (might not always be the caller of approve).

    • emitEvent: A boolean indicating whether to emit an Approval event (explained later).

More explanation of what_approvefunction does:

  • If auth is not the zero address, the owner is not the same as auth, and the owner hasn't already granted overall approval to auth using setApprovalForAll (checked by isApprovedForAll), then it throws a custom error ERC721InvalidApprover.
    This ensures that only authorized addresses can be approved on behalf of the owner. The internal _approve function checks if emitEvent is true or if auth is not the zero address (address(0)).
    If either condition is met, it proceeds to retrieve the current owner of the NFT using the _requireOwned(tokenId)(This function is also implemented inside ERC721 as a helper function that makes sure that the tokenId has an owner).

  • It checks if auth is not the zero address, and the owner is not the same as auth, and the owner hasn't already approved to auth, then it throws a custom error ERC721InvalidApprover. This ensures that only authorized addresses can be approved on behalf of the owner.

  • If emitEventis true, the function emits an Approval event.

  • mapping(uint256 tokenId => address) private _tokenApprovals;

    Regardless of event emission, the core action is assigning the to address to the internal mapping _tokenApprovals[tokenId].

    This mapping essentially stores the authorized address for each NFT within the contract.

setApprovalForAll

  function setApprovalForAll(address operator, bool approved) external;

This function grants transfer authority for all of an owner's NFTs within a specific ERC721 contract to another address (operator).
It enables NFT owners to set/assign a trusted party to manage and transfer their NFTs without requiring individual approvals for each token.

Creating our NFT

There have been some implementations of the ERC721 standard interface and in this article, we will be using the Openzepplin implementation.
See this implementation as a library, it already has an implementation of the standard interface so what you just need to do is import their implementation and use it to create your NFT.

Creating a structure for our token contract. We are inheriting the ERC721, which means we have access to all its functions and can use it in our token contract. The contract inherits from both ERC721 and ERC721URIStorage.

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

import "@openzeppelin/contracts/token/ERC721/ERC721.sol";
import "@openzeppelin/contracts/token/ERC721/extensions/ERC721URIStorage.sol";

contract RokiMarsNFT is ERC721, ERC721URIStorage {}

ERC721URIStorage helps us add support for token URIs. It provides a standard way to associate a token ID with a URI(Uniform Resource Identifier), allowing for the retrieval of off-chain metadata associated with each token.

In the Openzepllin implementation, we can pass the token name and symbol in the constructor, so let's do that.

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

import "@openzeppelin/contracts/token/ERC721/ERC721.sol";
import "@openzeppelin/contracts/token/ERC721/extensions/ERC721URIStorage.sol";

contract RokiMarsNFT is ERC721, ERC721URIStorage {
    constructor() ERC721("RokiMars", "ROKI") {}
}

Now, we need to make the NFT contract ownable which means, we want to be able to use the onlyOwner modifier on some functions which will give certain rights to the address that would pass when deploying the contract and that address would be called the owner.

So we would import and inherit ownable from Openzepplin.

Next, we set the initial owner upon deployment, using the constructor(a function that runs once upon deployment).

Then we create a function called safeMint and pass in the address we want to mint to and the tokenId as parameters. The onlyOwner is a modifier that is part of the things we inherited from Openzepplin.

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

import "@openzeppelin/contracts/token/ERC721/ERC721.sol";
import "@openzeppelin/contracts/token/ERC721/extensions/ERC721URIStorage.sol";
import "@openzeppelin/contracts/access/Ownable.sol";


contract RokiMarsNFT is ERC721, ERC721URIStorage, Ownable {

    constructor(address initialOwner)
        ERC721("RokiMars", "ROKI")
        Ownable(initialOwner){}

    function safeMint(address to, uint256 tokenId) public onlyOwner {
        _safeMint(to, tokenId);
    }
}

Every NFT should have a token ID, and with what we have now, we are adding a token ID every time we want to mint and that's not very efficient if we would like the ID to be accurate, as this would be prone to human error.

The solution to this is to auto-increment the ID every time we mint.
If you see below a lot has changed in our contract, let's talk about what changed:-

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

import "@openzeppelin/contracts/token/ERC721/ERC721.sol";
import "@openzeppelin/contracts/token/ERC721/extensions/ERC721URIStorage.sol";
import "@openzeppelin/contracts/access/Ownable.sol";

contract RokiMarsNFT is ERC721, ERC721URIStorage, Ownable {
    uint256 private nextTokenId;

    constructor(address initialOwner)
        ERC721("RokiMars", "ROKI")
        Ownable(initialOwner){}

    function safeMint(address to, string memory uri) public onlyOwner returns (uint256) {
        uint256 _tokenId = nextTokenId++;
        _safeMint(to, _tokenId);
        _setTokenURI(_tokenId, uri);
        return _tokenId;
    }

    // The following functions are overrides required by Solidity.
    function tokenURI(uint256 tokenId)
        public
        view
        override(ERC721, ERC721URIStorage)
        returns (string memory)
    {
        return super.tokenURI(tokenId);
    }

    function supportsInterface(bytes4 interfaceId)
        public
        view
        override(ERC721, ERC721URIStorage)
        returns (bool)
    {
        return super.supportsInterface(interfaceId);
    }
}
  1. We created a private variable called nextTokenId.

  2. For the _safeMint function, we added the token URI as a parameter.

  3. Then we removed the tokenId parameter from the safeMint function, as we don't need to addtokenId every time we want to mint since we want to auto-increment.

  4. Inside this function, the nextTokenId is used to determine the ID of the newly minted token. The ++ operator is used to increment nextTokenId and then assign its current value to _tokenId.

    Now each time the safeMint the function is called, it mints a new token with a unique ID by incrementing nextTokenId.

  5. tokenUrl and supportsInterface functions are required overrides required by solidity when you want to use ERC721URIStorage.

Building an NFTGatedEvent Contract

This NFTGatedEvent contract is built to manage events where participants are required to own a specific NFT. Here's an overview of the key components and functionalities:-

nftaddress

This variable stores the address of the NFT, which identifies the NFT. We want to validate that the user has the NFT before they can register for any of the events.

  address nftaddress;

eventCount

This is used to maintain a count of all the events that have been created.

    uint eventCount;

structEvent

This is a struct named "Event" to represent each event, including details such as title, description, venue, registered users, and event date.

    struct Event{
        uint eventId;
        string title;
        string description;
        string venue;
        address[] registeredUsers;
        string eventDate;
    }

eventsMapping

A mapping that we use to store events based on their IDs. This means using this mapping, you can pass in the ID and get a particular event with that ID.

    mapping (uint256 => Event) events;

createEventfunction

The createEvent function allows the creation of new events by providing essential details such as title, description, venue, and event date. Each event is assigned a unique event ID and stored in the events mapping and eventsArrays.
We made all the parameters required. So when an event creator wants to create an event they would have to enter all these fields before the event is created.

   function createEvent(
        string memory _title,
        string memory _description,
        string memory _venue,
        string memory _eventDate
    ) external {
        require(bytes(_title).length > 0, "Title is required");
        require(bytes(_description).length > 0, "Description is required");
        require(bytes(_venue).length > 0, "Venue is required");
        require(bytes(_eventDate).length > 0, "Event date is required");

        uint _newEventId = eventCount + 1;

        Event storage _event = events[_newEventId];
        _event.title = _title;
        _event.description = _description;
        _event.venue = _venue;
        _event.eventDate = _eventDate;

        eventsArrays.push(_event);

        eventCount = eventCount + 1;
    }

The registerForEvent function allows users to register for a specific event by providing the event ID. Before registration, it checks if the sender holds at least one NFT from the specified NFT contract and if the user has already registered for the event. If the conditions are met, the user is added to the list of registered users for the event.

    function registerForEvent(uint _eventId) external {
        require(IERC721(nftaddress).balanceOf(msg.sender) > 0, "Not enough");
        require(!hasRegistered[_eventId][msg.sender], "You have registered already");
        require(_eventId <= eventCount, "Event ID does not exist");

        Event storage _event = events[_eventId];

        _event.registeredUsers.push(msg.sender);

        hasRegistered[_eventId][msg.sender] = true;
    }

The getEvent function allows the retrieval of a specific event based on its ID, while the getAllEvents function returns an array containing all created events.


    function getEvent(uint256 _eventId) external  view returns(Event memory){
        return events[_eventId];
    }

    function getAllEvents() external  view returns(Event[] memory){
        return eventsArrays;
    }

You can get the full code here

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

import  "@openzeppelin/contracts/token/ERC721/IERC721.sol";

contract NFTGatedEvent{
    address nftaddress;

    uint eventCount;

    struct Event{
        uint eventId;
        string title;
        string description;
        string venue;
        address[] registeredUsers;
        string eventDate;
    }

    mapping (uint256 => Event) events;

    mapping (uint256 => mapping (address => bool)) hasRegistered;

    Event[] eventsArrays;

    function createEvent(
        string memory _title,
        string memory _description,
        string memory _venue,
        string memory _eventDate
    ) external {
        require(bytes(_title).length > 0, "Title is required");
        require(bytes(_description).length > 0, "Description is required");
        require(bytes(_venue).length > 0, "Venue is required");
        require(bytes(_eventDate).length > 0, "Event date is required");

        uint _newEventId = eventCount + 1;

        Event storage _event = events[_newEventId];
        _event.title = _title;
        _event.description = _description;
        _event.venue = _venue;
        _event.eventDate = _eventDate;

        eventsArrays.push(_event);

        eventCount = eventCount + 1;
    }

    function registerForEvent(uint _eventId) external {
        require(IERC721(nftaddress).balanceOf(msg.sender) > 0, "Not enough");
        require(!hasRegistered[_eventId][msg.sender], "You have registered already");
        require(_eventId <= eventCount, "Event ID does not exist");

        Event storage _event = events[_eventId];

        _event.registeredUsers.push(msg.sender);

        hasRegistered[_eventId][msg.sender] = true;
    }

    function getEvent(uint256 _eventId) external  view returns(Event memory){
        return events[_eventId];
    }

    function getAllEvents() external  view returns(Event[] memory){
        return eventsArrays;
    }
}

Thanks for reading 💖