Writing an ERC-721 token from scratch using Solidity

Writing an ERC-721 token from scratch using Solidity

ยท

14 min read

Non-fungible tokens (NFTs) have gained much popularity in recent months. Every day, we see new collections popping up in the space, and some of them even have cool utilities such as community access, software access, and so on. Deploying smart contracts for such tokens is not a difficult task. You can find many tools, for example, thirdweb, that allows you to deploy such a contract with just a few clicks. You can even use libraries like OpenZeppelin that assist you with libraries while programming such a contract.

This tutorial will explore writing an ERC-721 token from scratch using Solidity. To create an ERC-721 token, we will need to use Solidity, the primary programming language for writing smart contracts on Ethereum. This tutorial will teach you how to set up your development environment and deploy your ERC-721 token to the blockchain. By the end of this tutorial, you will have a solid understanding of how to create an ERC-721 token from scratch using Solidity.

We will follow the official ethereum.org ERC-721 documentation

and create an implementation around it. I'd recommend opening that link in a browser tab and constantly referring to it when we create a new function.

If you prefer video tutorials, I've got you covered; check out my video on writing an ERC-721 token from scratch using Solidity on my YouTube channel. Also, would you be interested in writing an ERC-20 token from scratch using Solidity?

So, let's start with the craziness ๐Ÿš€

Setting up the development environment

Creating a hardhat project

We will use hardhat in this tutorial to compile and manage our contract. Navigate to a safe directory, and run the following command to initialize the hardhat project initialization wizard:

npx hardhat

This should load up the initializer. Choose your configuration; in this case, I'll be choosing JavaScript (although we won't be writing scripts in this tutorial, although if you wish to do so, this choice might be important for you). Finally, choose to install the dependencies for your hardhat project.

Creating the contract file

Hardhat creates a file named Lock.sol within the contracts folder with some example code for you. You can delete the file and create a new file named ERC721.sol. Then you can initialize a simple contract.

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

contract ERC721 {

}

Writing the contract

Defining the states

Our contract will have states that will store important data about the tokens and owners. Following are the states we will be using in our contract.

string public name;
string public symbol;

uint256 public nextTokenIdToMint;
address public contractOwner;

// token id => owner
mapping(uint256 => address) internal _owners;
// owner => token count
mapping(address => uint256) internal _balances;
// token id => approved address
mapping(uint256 => address) internal _tokenApprovals;
// owner => (operator => yes/no)
mapping(address => mapping(address => bool)) internal _operatorApprovals;
// token id => token uri
mapping(uint256 => string) _tokenUris;

Now, let's look at what each state is responsible for storing.

  • name: name of the token, can be read by apps to know the collection's name.

  • symbol: symbol for the token, just like an ERC-20 token.

  • nextTokenIdToMint: indicates the token ID for the next NFT that will be minted. This state keeps track of the next token ID and ensures no two NFTs have the same token ID.

  • contractOwner: this will be set to msg.sender in the constructor. We are using this to restrict token minting to the contract owner.

  • _owners: this internal state keeps track of token IDs and their owners.

  • _balances: this internal state keeps track of how many tokens an address owns.

  • _tokenApprovals: this internal state keeps track of any addresses allowed to manage someone's else tokens. This approval is based on token ID and works best when you don't want someone to manage all your tokens but specific tokens.

  • _operatorApprovals: this internal state keeps track of all the operators for a wallet. These operators can manage all the tokens owned by an address for that specific ERC-721 contract.

  • _tokenUris: this state keeps track of all the token URIs that lead to a JSON file with all the NFT metadata. This is not a part of the standard and is optional. However, we cover it because most NFT collections have metadata on their NFTs.

Defining the events

We will use the events that are specified in the ERC-721 documentation. Apps can listen to these events and track any changes. Let's define these events beforehand.

event Transfer(address indexed _from, address indexed _to, uint256 indexed _tokenId);
event Approval(address indexed _owner, address indexed _approved, uint256 indexed _tokenId);
event ApprovalForAll(address indexed _owner, address indexed _operator, bool _approved);

Creating the constructor

Now, we will create a constructor to initialize values when the contract is deployed.

constructor(string memory _name, string memory _symbol) {
    name = _name;
    symbol = _symbol;
    nextTokenIdToMint = 0;
    contractOwner = msg.sender;
}

In the above code, we are setting the name and symbol of the contract as described by the contract deployer; we are also initializing nextTokenIdToMint it as 0, making the first token ID to be 0, and finally, we're setting the contract owner value as msg.sender

Creating the balanceOf() function

Let's create the balanceOf() function that will help us get the wallet's token balance (number of NFTs owned).

 function balanceOf(address _owner) public view returns(uint256) {
    require(_owner != address(0), "!Add0");
    return _balances[_owner];
}

The above function will access the _balances mapping and return the balance of the passed wallet address.

Creating the ownerOf() function

Let's create the ownerOf() function that will help us get the wallet address of the owner of a specific token ID.

function ownerOf(uint256 _tokenId) public view returns(address) {
    return _owners[_tokenId];
}

If an invalid token ID is passed, a zero address will be returned as the token is yet to be minted.

About safeTransferFrom() function

If you look at the standards of an ERC721 token, you will see two types of transfer functions, transferFrom() and safeTransferFrom(). The transferFrom() function is pretty straightforward; it allows you to send NFTs from one address to another. However, the safeTransferFrom() function does more than that; it checks if the recipient can receive ERC-721 tokens and if not, the transaction is reverted. Many people accidentally send their NFTs to contracts that aren't meant to receive these tokens, and then they are locked up forever with no means to retrieve them. The safeTransferFrom() function is a way to implement safe transfers.

The way to check whether the recipient contract can receive ERC-721 tokens is by checking if they have a onERC721Received() function, which automatically means they mean to receive ERC-721 tokens. If the function does not exist on the recipient contract, the transaction is reverted to save the token from being locked up forever.

Preparing to create safeTransferFrom() function

Let's first create an unsafe internal function to transfer the token, no questions asked. We can then wrap this function around other functions and do some checks.

// unsafe transfer
function _transfer(address _from, address _to, uint256 _tokenId) internal {
    require(ownerOf(_tokenId) == _from, "!Owner");
    require(_to != address(0), "!ToAdd0");

    delete _tokenApprovals[_tokenId];
    _balances[_from] -= 1;
    _balances[_to] += 1;
    _owners[_tokenId] = _to;

    emit Transfer(_from, _to, _tokenId);
}

The above function will check if the _from is the owner of _tokenId and proceed to delete the token approvals and transfer the token to the _to address by changing the _balances and _owners mappings. Finally, we emit a Transfer event so that apps can listen to any transfers made on the contract.

Now, let's create an interface for the onERC721Received() function so that we can use the interface to access the function in the recipient contract. Create a new file in the contracts folder named IERC721Receiver.sol and have the following contents.

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

interface IERC721Receiver {
    function onERC721Received(
        address operator,
        address from,
        uint256 tokenId,
        bytes calldata data
    ) external returns (bytes4);
}

The above code is just a simple interface that defines the onERC721Received() function. Now let's go back to our main ERC721.sol file.

Now let's create an internal function that checks whether the recipient contract has the onERC721Received() function.

function _checkOnERC721Received(
    address from,
    address to,
    uint256 tokenId,
    bytes memory data
) private returns (bool) {
    // check if to is an contract, if yes, to.code.length will always > 0
    if (to.code.length > 0) {
        try IERC721Receiver(to).onERC721Received(msg.sender, from, tokenId, data) returns (bytes4 retval) {
            return retval == IERC721Receiver.onERC721Received.selector;
        } catch (bytes memory reason) {
            if (reason.length == 0) {
                revert("ERC721: transfer to non ERC721Receiver implementer");
            } else {
                /// @solidity memory-safe-assembly
                assembly {
                    revert(add(32, reason), mload(reason))
                }
            }
        }
    } else {
        return true;
    }
}

The above code looks pretty complicated because it is. Most of the contracts use the above implementation to check whether the function exists on the recipient contract. First, we check if the recipient is a contract (such checks are not carried out on wallet accounts). Then we try to access the onERC721Received() function on the recipient contract and check if we get a solidity selector back. If anything goes wrong or the recipient doesn't implement the function, we revert the transaction, preventing any token transfers from materializing.

Creating the safeTransferFrom() function

Finally, now let's implement the safeTransferFrom() function.

function safeTransferFrom(address _from, address _to, uint256 _tokenId) public payable {
    safeTransferFrom(_from, _to, _tokenId, "");
}

function safeTransferFrom(address _from, address _to, uint256 _tokenId, bytes memory _data) public payable {
    require(ownerOf(_tokenId) == msg.sender || _tokenApprovals[_tokenId] == msg.sender || _operatorApprovals[ownerOf(_tokenId)][msg.sender], "!Auth");
    _transfer(_from, _to, _tokenId);
    // trigger func check
    require(_checkOnERC721Received(_from, _to, _tokenId, _data), "!ERC721Implementer");
}

We are using function overloading in the above code by creating the same function name with different parameters. One of them has an extra _data parameter. The function without the _data parameter simply calls the one with the _data parameter but with empty data. So finally, we end up inside the one with _data as the parameter.

In the function, we check if the transaction initiator is either the owner of the token, approved for the token ID or is an operator for the token owner. If not, we will revert the transaction.

Then we perform the transfer. After the states have updated, we trigger the _checkOnERC721Received() function. If it fails, the transaction is reverted, the state changes never materialize, and the transfer never takes place.

Creating the transferFrom() function

If the user understands the risk and wants to transfer the tokens, they can use the transferFrom() function. It's the same function without any contract checks.

function transferFrom(address _from, address _to, uint256 _tokenId) public payable {
    // unsafe transfer without onERC721Received, used for contracts that dont implement
    require(ownerOf(_tokenId) == msg.sender || _tokenApprovals[_tokenId] == msg.sender || _operatorApprovals[ownerOf(_tokenId)][msg.sender], "!Auth");
    _transfer(_from, _to, _tokenId);
}

Creating the approve() function

The approve() function will let someone transfer a specific token owned by you on your behalf.

function approve(address _approved, uint256 _tokenId) public payable {
    require(ownerOf(_tokenId) == msg.sender, "!Owner");
    _tokenApprovals[_tokenId] = _approved;
    emit Approval(ownerOf(_tokenId), _approved, _tokenId);
}

In the above code, we are checking if the owner of the specified token ID is the transaction initiator; if not, we revert. Then we update the _tokenApprovals mapping and emit the Approval event.

Creating the setApprovalForAll() function

The setApprovalForAll() function allows you to authorize someone to manage all the NFTs on the contract.

function setApprovalForAll(address _operator, bool _approved) public {
    _operatorApprovals[msg.sender][_operator] = _approved;
    emit ApprovalForAll(msg.sender, _operator, _approved);
}

This function requires no check; we can directly make changes to _operatorApprovals and emit ApprovalForAll event.

Creating the getApproved() and isApprovedForAll() functions

These functions will return the token or operator approval status.

function getApproved(uint256 _tokenId) public view returns (address) {
    return _tokenApprovals[_tokenId];
}

function isApprovedForAll(address _owner, address _operator) public view returns (bool) {
    return _operatorApprovals[_owner][_operator];
}

In the above code, we are simply accessing the mappings _tokenApprovals and _operatorApprovals and returning the approval status.

Creating the mintTo() function

This function will allow the contract owner to mint NFTs into the collection.

function mintTo(address _to, string memory _uri) public {
    require(contractOwner == msg.sender, "!Auth");
    _owners[nextTokenIdToMint] = _to;
    _balances[_to] += 1;
    _tokenUris[nextTokenIdToMint] = _uri;
    emit Transfer(address(0), _to, nextTokenIdToMint);
    nextTokenIdToMint += 1;
}

In the above function, we check if the transaction initiator is the contract owner; if not, we revert the transaction. If it is the owner, we proceed with manipulating the _owners, _balances and _tokenUris mappings. Note how we use the nextTokenIdToMint here. Finally, we emit a Transfer event with from address as zero address event and increment nextTokenIdToMint by 1.

Creating the tokenURI() and totalSupply() functions

function tokenURI(uint256 _tokenId) public view returns(string memory) {
    return _tokenUris[_tokenId];
}

function totalSupply() public view returns(uint256) {
    return nextTokenIdToMint;
}

The tokenURI function is used to get a token URI for any specific token ID to access metadata for any given token ID.

The totalSupply function returns the number of NFTs minted. Since we start from zero, the total supply at any point happens to be nextTokenIdToMint. If you are starting from other than token ID 0, I'd recommend changing this function.

Final code

Here's the final code for the ERC-721 contract! First, ERC721.sol:

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

import './IERC721Receiver.sol';

contract ERC721 {
    string public name;
    string public symbol;

    uint256 public nextTokenIdToMint;
    address public contractOwner;

    // token id => owner
    mapping(uint256 => address) internal _owners;
    // owner => token count
    mapping(address => uint256) internal _balances;
    // token id => approved address
    mapping(uint256 => address) internal _tokenApprovals;
    // owner => (operator => yes/no)
    mapping(address => mapping(address => bool)) internal _operatorApprovals;
    // token id => token uri
    mapping(uint256 => string) _tokenUris;

    event Transfer(address indexed _from, address indexed _to, uint256 indexed _tokenId);
    event Approval(address indexed _owner, address indexed _approved, uint256 indexed _tokenId);
    event ApprovalForAll(address indexed _owner, address indexed _operator, bool _approved);

    constructor(string memory _name, string memory _symbol) {
        name = _name;
        symbol = _symbol;
        nextTokenIdToMint = 0;
        contractOwner = msg.sender;
    }

    function balanceOf(address _owner) public view returns(uint256) {
        require(_owner != address(0), "!Add0");
        return _balances[_owner];
    }

    function ownerOf(uint256 _tokenId) public view returns(address) {
        return _owners[_tokenId];
    }

    function safeTransferFrom(address _from, address _to, uint256 _tokenId) public payable {
        safeTransferFrom(_from, _to, _tokenId, "");
    }

    function safeTransferFrom(address _from, address _to, uint256 _tokenId, bytes memory _data) public payable {
        require(ownerOf(_tokenId) == msg.sender || _tokenApprovals[_tokenId] == msg.sender || _operatorApprovals[ownerOf(_tokenId)][msg.sender], "!Auth");
        _transfer(_from, _to, _tokenId);
        // trigger func check
        require(_checkOnERC721Received(_from, _to, _tokenId, _data), "!ERC721Implementer");
    }

    function transferFrom(address _from, address _to, uint256 _tokenId) public payable {
        // unsafe transfer without onERC721Received, used for contracts that dont implement
        require(ownerOf(_tokenId) == msg.sender || _tokenApprovals[_tokenId] == msg.sender || _operatorApprovals[ownerOf(_tokenId)][msg.sender], "!Auth");
        _transfer(_from, _to, _tokenId);
    }

    function approve(address _approved, uint256 _tokenId) public payable {
        require(ownerOf(_tokenId) == msg.sender, "!Owner");
        _tokenApprovals[_tokenId] = _approved;
        emit Approval(ownerOf(_tokenId), _approved, _tokenId);
    }

    function setApprovalForAll(address _operator, bool _approved) public {
        _operatorApprovals[msg.sender][_operator] = _approved;
        emit ApprovalForAll(msg.sender, _operator, _approved);
    }

    function getApproved(uint256 _tokenId) public view returns (address) {
        return _tokenApprovals[_tokenId];
    }

    function isApprovedForAll(address _owner, address _operator) public view returns (bool) {
        return _operatorApprovals[_owner][_operator];
    }

    function mintTo(address _to, string memory _uri) public {
        require(contractOwner == msg.sender, "!Auth");
        _owners[nextTokenIdToMint] = _to;
        _balances[_to] += 1;
        _tokenUris[nextTokenIdToMint] = _uri;
        emit Transfer(address(0), _to, nextTokenIdToMint);
        nextTokenIdToMint += 1;
    }

    function tokenURI(uint256 _tokenId) public view returns(string memory) {
        return _tokenUris[_tokenId];
    }

    function totalSupply() public view returns(uint256) {
        return nextTokenIdToMint;
    }

    // INTERNAL FUNCTIONS
    function _checkOnERC721Received(
        address from,
        address to,
        uint256 tokenId,
        bytes memory data
    ) private returns (bool) {
        // check if to is an contract, if yes, to.code.length will always > 0
        if (to.code.length > 0) {
            try IERC721Receiver(to).onERC721Received(msg.sender, from, tokenId, data) returns (bytes4 retval) {
                return retval == IERC721Receiver.onERC721Received.selector;
            } catch (bytes memory reason) {
                if (reason.length == 0) {
                    revert("ERC721: transfer to non ERC721Receiver implementer");
                } else {
                    /// @solidity memory-safe-assembly
                    assembly {
                        revert(add(32, reason), mload(reason))
                    }
                }
            }
        } else {
            return true;
        }
    }

    // unsafe transfer
    function _transfer(address _from, address _to, uint256 _tokenId) internal {
        require(ownerOf(_tokenId) == _from, "!Owner");
        require(_to != address(0), "!ToAdd0");

        delete _tokenApprovals[_tokenId];
        _balances[_from] -= 1;
        _balances[_to] += 1;
        _owners[_tokenId] = _to;

        emit Transfer(_from, _to, _tokenId);
    }
}

The following is the code for IERC721Receiver.sol:

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

interface IERC721Receiver {
    function onERC721Received(
        address operator,
        address from,
        uint256 tokenId,
        bytes calldata data
    ) external returns (bytes4);
}

Testing the contract

Typically to deploy and test the contract functions, you'll need to write scripts and add private keys to your environment variables to deploy and interact with the contract. However, thirdweb deploy provides an easy and interactive workflow for deploying your contracts on the blockchain without exposing your private keys - and it's FREE! You don't need any additional setup- run the following command in the terminal to begin deploying the contract.

npx thirdweb@latest deploy

This command will do all the hard work- compiling the contract and uploading the data to IPFS, and then you will be redirected to your browser to pass in the parameters to deploy the contract finally.

As you can see, thirdweb detects that our contract is an ERC-721 contract with the capability of checking the supply (using totalSupply() function), and also it has the capability to mint tokens (using mintTo() function).

Enter your desired _name and _symbol. Choose your desired network and click on deploy now. This should initiate a transaction on your wallet provider (for ex, MetaMask, WalletConnect, etc.), and on approval, your transaction will be deployed on the blockchain. No private keys involved. After deploying, you can go to the NFTs tab on the top menu and click on Mint. Then you can see a drawer like this:

You can enter all the data, and thirdweb will automatically upload all your assets to IPFS, generate a JSON file, upload it to IPFS and invoke the mintTo() function for you! And all of this is FREE! For example, I just used this to mint my favourite Naruto character- Madara Uchiha.

You can now go to the Explorer tab and directly interact with the contract functions. Check out my video if you want to see me test this a bit more!

Conclusion

In this article, we saw how to write an ERC-721 token from scratch. I'd say every Solidity developer must do this at least once, as this makes you understand the basics of Solidity a lot better than the regular to-do list contracts.

If you have any suggestions or questions, feel free to leave them in the comments below or contact me on other platforms.

ย