How to Create ERC-721 NFTs on Ethereum with OpenZeppelin: A Step-by-Step Tutorial

Rosario Borgesi
Coinmonks
16 min readMar 30, 2024

--

Welcome to a deep exploration of non-fungible tokens (NFTs) on Ethereum! In this Medium story, we are going to dive deep into what NFTs are and how you can make your own using the ERC-721 standard.

Throughout this article, we will break down the different types of contracts and what they are used for. Then, we will jump into a step-by-step tutorial where we will create our very own NFTs. We will use the Solidity Wizard from Open Zeppelin to build our smart contracts, and I will show you how to set up a Hardhat project for testing and deploying your contract. Finally, we will deploy the contract on the Sepolia test network and verify the resulting NFT collection on OpenSea.

By the end of this journey, you will have the skills to deploy your NFT smart contract on any Ethereum network and see it in action. Get ready to learn and have some fun exploring the world of NFTs!

If you prefer I have also covered the topics of this story in this video:

Table of Contents

· Table of Contents
· Tokens
· What is the ERC-721 Standard
· Basic ERC-721 Contract
Imports
Constructor
SafeMint
Hardhat project
· Add metadata to the ERC721 Contract
· URI Storage, Burnable, Pausable, Enumerable
· Further Exploration
· Conclusions
· Resources

Tokens

A token serves as a representation of a digital or physical asset on the blockchain. It can depict various items such as watches, real estate, stocks, bonds, currencies (like USD), stablecoins (like USDT), an once of gold or items of a videogame. However tokens can be classified into two types:

  1. Fungible Tokens: Each token is identical to others, akin to how one dollar is equivalent to another dollar.
  2. Non-fungible Tokens (NFTs): Each token possesses unique attributes, much like how one apartment differs from another.

If you want are new to these topics, I suggest you to read these two stories:

  1. Fungible tokens
  2. Non fungible tokens or NFTs

What is the ERC-721 Standard

To create Non-Fungible Tokens (NFTs) in a standardized manner on Ethereum, the ERC-721 standard has been established. This standard enables various functionalities, such as facilitating token transfers between accounts, checking an account’s token balance, identifying token ownership, and determining the total token supply on the network. Additionally, it permits the approval of third-party accounts to transfer a specified token amount from another account. Essentially, for compliance with the ERC-721 standard, a smart contract must implement the following methods:

function balanceOf(address _owner) external view returns (uint256);
function ownerOf(uint256 _tokenId) external view returns (address);
function safeTransferFrom(address _from, address _to, uint256 _tokenId, bytes data) external payable;
function safeTransferFrom(address _from, address _to, uint256 _tokenId) external payable;
function transferFrom(address _from, address _to, uint256 _tokenId) external payable;
function approve(address _approved, uint256 _tokenId) external payable;
function setApprovalForAll(address _operator, bool _approved) external;
function getApproved(uint256 _tokenId) external view returns (address);
function isApprovedForAll(address _owner, address _operator) external view returns (bool);

And events:

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);

All of the methods from the ERC-721 standard have been thoroughly discussed in this story. However, there is no need for us to implement these functions and events because OpenZeppelin already provides the implementations in the ERC721 contract, which can be found on GitHub.

OpenZeppelin provides also some extensions of the ERC721 contract that provide additional features to the basic contract like:

  • ERC721Enumerable: The enumerable extension. Adds the enumerability of all token ids in the contract as well as token ids owned by each account
  • ERC721Burnable: allows token holders to burn their own tokens.
  • ERC721Pausable: allows to pause token transfers, minting and burning
  • ERC721URIStorage: it adds support for storing Uniform Resource Identifiers (URIs) associated with each token. These URIs typically point to external resources such as metadata or images related to the token. In other words it allows developers to associate metadata or other external information with each NFT token, making it easier to retrieve and display additional details about the token.

Basic ERC-721 Contract

First, let’s learn how to deploy a basic ERC-721 contract. To create the contract, we can utilize the OpenZeppelin Solidity Wizard.

On the left side, we’ve chosen to create an ERC721 contract. We’ve named the token collection “Football Players” and selected “FTP” as its symbol. The selected features include:

  • Mintable
  • Auto Increment Ids
  • Ownable

On the right side, the wizard has generated the corresponding Solidity code based on the selected features. However, I have made some slight modifications to the auto-generated code:

// SPDX-License-Identifier: MIT
// Compatible with OpenZeppelin Contracts ^5.0.0
pragma solidity ^0.8.24;

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

contract FootballPlayers is ERC721, Ownable {
uint256 private _nextTokenId;

constructor()
ERC721("Football Players", "FTP")
Ownable(msg.sender)
{}

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

Imports

We can see that a contract named FootballPlayers has been created, which inherits from two other contracts: ERC721 and Ownable. This indicates that the FootballPlayers contract will inherit the implementations of all the methods of the ERC721 standard (such as balanceOf, ownerOf, etc.) that we discussed earlier.

Constructor

Inside the constructor of the FootballPlayers contract, the constructor of the ERC721 contract is called, passing the name of the token collection and its symbol as arguments. Additionally, the constructor of the Ownable contract is invoked, specifying that the owner of the contract will be msg.sender, which is the address deploying the contract.

SafeMint

Since we selected the token to be mintable, the Wizard has created the function safeMint for minting or creating new NFTs. As we chose the Ownable feature, the Wizard has added the onlyOwner modifier to this function. This modifier, defined in the Ownable contract, ensures that only the owner of the contract can create new NFTs or tokens. Without this modifier, any address could mint new tokens.

Each time the safeMint function is called, it increments the _nextTokenId variable. This counter is used to generate a unique identifier (tokenId) for each NFT in the collection. The Wizard created this counter because we selected the Auto Increment Ids feature.

Finally, the safeMint function calls the _safeMint function, which is a function of the ERC721 contract. This function actually mints a new NFT with the tokenId passed as an argument and transfers it to the specified address (to). If the recipient is a smart contract, it must implement the onERC721Received method of the IERC721Receiver interface. This method is called upon a safe transfer.

Hardhat project

Now we will set up a Hardhat project to compile, test, and deploy the FootballPlayer contract. For those unfamiliar with Hardhat, I recommend checking out this guide.
We can use the following commands to create the project:

npm init -y

npm install -D hardhat

npx hardhat init --> choose Typescript

npm install -D dotenv

npm install @openzeppelin/contracts

npm install -D @nomicfoundation/hardhat-ignition-ethers

Add your private key and your sepolia endpoint to the .env file:

PRIVATE_KEY="your_private_key"
INFURA_SEPOLIA_ENDPOINT="your_Infura_api_key"

Add the ignition module FootballPlayers.ts in the ignition/module directory

Modify the file hardhat.config.ts to connect to Sepolia

Add the contract FootballPlayers.sol in the contracts directory

Add the test file FootballPlayers.ts in the test directory

The ignition module (FootballPlayers.ts) is used deploy the contract:

import { buildModule } from "@nomicfoundation/hardhat-ignition/modules";

const CONTRACT_NAME = "FootballPlayers";

export default buildModule(CONTRACT_NAME, (m) => {
const contract = m.contract(CONTRACT_NAME, );
return { contract };
});

In the hardhat.config.ts we must specify the provider and the private key of our account to connect to the Sepolia test network:

import { HardhatUserConfig } from "hardhat/config";
import "@nomicfoundation/hardhat-toolbox";
import dotenv from 'dotenv';

dotenv.config();

const config: HardhatUserConfig = {
solidity: "0.8.24",
networks: {
sepolia: {
url: process.env.INFURA_SEPOLIA_ENDPOINT,
accounts: [process.env.PRIVATE_KEY ?? ""]
}
}
};

export default config;

In the following file, FootballPlayers.ts, you can find some basic tests for our contract:

import { expect } from "chai";
import { ethers } from "hardhat";
import { HardhatEthersSigner } from "@nomicfoundation/hardhat-ethers/signers";
import { FootballPlayers} from "../typechain-types";

describe("FootballPlayers", () => {
let footballPlayers: FootballPlayers;
let owner: HardhatEthersSigner;
let addr1: HardhatEthersSigner;
let addr2: HardhatEthersSigner;

beforeEach(async () => {
footballPlayers = await ethers.deployContract("FootballPlayers");
[owner, addr1, addr2] = await ethers.getSigners();
});

describe("Deployment", () => {
it("Should set the right owner", async () => {
expect(await footballPlayers.owner()).to.equal(owner.address);
});

it("Should assign the contract name and symbol correctly", async () => {
expect(await footballPlayers.name()).to.equal("Football Players");
expect(await footballPlayers.symbol()).to.equal("FTP");
});
});

describe("Minting", () => {
it("Should mint a new token and assign it to owner", async () => {
await footballPlayers.safeMint(owner.address);
expect(await footballPlayers.ownerOf(0)).to.equal(owner.address);
});

it("Should fail if non-owner tries to mint", async () => {
await expect(footballPlayers.connect(addr1).safeMint(addr1.address))
.to.be.revertedWithCustomError(footballPlayers, "OwnableUnauthorizedAccount");
});

it("Should auto increment tokenId with each new minted token", async () => {
await footballPlayers.safeMint(await owner.getAddress());
expect(await footballPlayers.ownerOf(0)).to.equal(await owner.getAddress());

await footballPlayers.safeMint(await owner.getAddress());
expect(await footballPlayers.ownerOf(1)).to.equal(await owner.getAddress());
});
});

describe("Token transferring", () => {
it("Should transfer a token from one address to another", async () => {
await footballPlayers.safeMint(await owner.getAddress());
await footballPlayers["safeTransferFrom(address,address,uint256)"](owner, addr1, 0);
expect(await footballPlayers.ownerOf(0)).to.equal(await addr1.getAddress());
});

it("Should revert when trying to transfer a token that is not owned", async () => {
await footballPlayers.safeMint(await owner.getAddress());
await expect(
footballPlayers.connect(addr1)["safeTransferFrom(address,address,uint256)"] (addr1, addr2, 0)
).to.be.revertedWithCustomError(footballPlayers, "ERC721InsufficientApproval");
});

it("Should not send tokens to the 0 address", async () => {
await footballPlayers.safeMint(await owner.getAddress());
await expect(
footballPlayers["safeTransferFrom(address,address,uint256)"] (owner, ethers.ZeroAddress, 0)
).to.be.revertedWithCustomError(footballPlayers, "ERC721InvalidReceiver");
});
});

// Add more tests for other functionalities of your contract.
});
To compile the contract run: npx hardhat compile
To test the contract run: npx hardhat test test/FootballPlayers.ts --typecheck
To deploy the contract to Sepolia run: npx hardhat ignition deploy ignition/modules/FootballPlayers.ts --network sepolia

You can find the entire code at this repository.

Add metadata to the ERC721 Contract

We have learned how to create and deploy a smart contract that allows us to mint new tokens. Now, we need to learn how to attach metadata to it. Metadata provides descriptive information for a specific token ID, such as the name, image, and description. If you want to know more check out this page.

Metadata can be stored on the blockchain (i.e on-chain) but is costly, so it’s a good practice to store the data off-chain. A great way to do this is by using IPFS (InterPlanetary File System). IPFS is a peer-to-peer distributed file storage system that allows you to locate an element not by its location, as in the HTTP protocol, but rather by its content. This is because IPFS hashes each file, allowing us to find a file by its hash, acting like a fingerprint, instead of providing the path to its location as in the HTTP protocol. If you want to learn more about IPFS I suggest you to check out this story.

To understand how it works, let’s create a collection of NFTs, where each player will represent a football player and will be associated with one of the following images that have been created with Microsoft Copilot (DALL-E), using a prompt like this:

Generate a photo of a forward soccer player who is about to kick the ball. 
The player is male and is wearing the Italian national team’s blue jersey,
white shorts, and blue socks with the number 10.
He is in a real soccer stadium. The image must be realistic and in color.
Only the player and the ball are on the field.
Images generated with Microsoft Copilot

We need to upload each image and its associated JSON file. So, for the Italian player, I created an image called “0.jpeg” and uploaded it to nft.storage, which allow us to upload files to IPFS. Then, I copied the generated IPFS URL:

ipfs://bafkreicdgbzytjd7a3yjd446wg56meafmixwu6rxe2zhcc6psbzxjrnkkm

Next, we need to add this URL to the image field of the JSON metadata related to our Italian player, which we name “0.json”:

{
"name": "Football Players #0",
"description": "He is an Italian football player for the national team, created by AI",
"image": "ipfs://bafkreicdgbzytjd7a3yjd446wg56meafmixwu6rxe2zhcc6psbzxjrnkkm"
}

Then, we have to replicate this process for all the other players. Afterward, we can create a new folder and paste all the JSON files for each player inside it. Afterwards we should upload the entire folder to IPFS using NFT UP (which can be found here). The folder is located at the following IPFS URL:

ipfs://bafybeiag6fokmiz6xmjodjyeuejtgrbyf2moirydovrew2bhmxjrehernq

And it contains the JSON files with the metadata of each token that we will mint:

Now we need to link each token with the related JSON file hosted on IPFS. For this purpose, the ERC-721 standard includes the tokenURI method. Here is the ERC721 implementation:

function tokenURI(uint256 tokenId) public view virtual returns (string memory) {
_requireOwned(tokenId);
string memory baseURI = _baseURI();
return bytes(baseURI).length > 0 ? string.concat(baseURI, tokenId.toString()) : "";
}

One way to create the tokenURI is by overriding the method _baseURI() to point to the IPFS folder containing all the JSON files:

function _baseURI() internal pure override returns (string memory) {
return "ipfs://bafybeiag6fokmiz6xmjodjyeuejtgrbyf2moirydovrew2bhmxjrehernq/";
}

Then we can override the tokenURI method in this way:

function tokenURI(uint256 tokenId) public view virtual override returns (string memory) {
_requireOwned(tokenId);
return string(abi.encodePacked(_baseURI(), Strings.toString(tokenId), ".json"));
}

We just need to pass the token ID to the tokenURI function, and the function will automatically retrieve the JSON file associated with the token. In fact, the tokenURI is the concatenation of the following strings:

IPFS folder + token ID + “.json”

So for instance if we are looking for the JSON metadata of the token #3, our tokenURI will be:

ipfs://bafybeiag6fokmiz6xmjodjyeuejtgrbyf2moirydovrew2bhmxjrehernq/3.json

This solution is very useful if we know beforehand how many tokens will be minted and what metadata will be associated with them. It also allows us to use fewer gas fees. However, we need to bear in mind that once the folder is uploaded to IPFS, it is immutable, so we cannot add new metadata files to the IPFS folder for other tokens.

The FootballPlayers contract has been updated to:

// SPDX-License-Identifier: MIT
// Compatible with OpenZeppelin Contracts ^5.0.0
pragma solidity ^0.8.24;

import "@openzeppelin/contracts/token/ERC721/ERC721.sol";
import "@openzeppelin/contracts/access/Ownable.sol";
import "@openzeppelin/contracts/utils/Strings.sol";

contract FootballPlayers is ERC721, Ownable {
uint16 private TOKEN_CAP = 4;
uint256 private _nextTokenId;

constructor()
ERC721("Football Players", "FTP")
Ownable(msg.sender)
{}

function _baseURI() internal pure override returns (string memory) {
return "ipfs://bafybeiag6fokmiz6xmjodjyeuejtgrbyf2moirydovrew2bhmxjrehernq/";
}

function safeMint(address to) public onlyOwner {
require (_nextTokenId <= TOKEN_CAP, "Token cap exceeded");
uint256 tokenId = _nextTokenId++;
_safeMint(to, tokenId);
}

function tokenURI(uint256 tokenId) public view virtual override returns (string memory) {
_requireOwned(tokenId);
return string(abi.encodePacked(_baseURI(), Strings.toString(tokenId), ".json"));
}
}

We can also add a test for the tokenURI:

it("Should return the right tokenURI", async () => {
const BASE_URI: String = "ipfs://bafybeiatjpa32sftvqpbohuo7gvjp3wvcvzxajk4erbktythfcr2ob4goe/"
await footballPlayers.safeMint(owner.address);
expect(await footballPlayers.tokenURI(0)).to.equal(BASE_URI + "0.json");
});

And the token cap:

it("Should not mint more than TOKEN_CAP tokens", async () => {
const TOKEN_CAP = 4;
for (let i = 0; i <= TOKEN_CAP; i++) {
await footballPlayers.safeMint(owner.address);
}
await expect(footballPlayers.safeMint(owner.address)).to.be.revertedWith("Token cap exceeded");
});

You can find the code here.
The contract has been deployed at the address 0xc98d74ad6E868C3627D47bd27de49D48b648241F on Sepolia. Then, the safeMint function of the contract was called five times to mint the NFTs. This can be achieved either from Remix using the GUI or from the Hardhat console with the following commands:

npx hardhat console --network sepolia
const FootballPlayers = await ethers.getContractFactory('FootballPlayers');
const footballPlayersContract = await FootballPlayers.attach('0xc98d74ad6E868C3627D47bd27de49D48b648241F');
const wallet = new ethers.Wallet('private_key', 'infura_sepolia_api_key');
await footballPlayersContract.safeMint(wallet.address); //5 times

From Opensea you can browse the resulting NFT collection.

URI Storage, Burnable, Pausable, Enumerable

Let’s now create a more complex ERC721 from the Solidity Wizard by choosing the following features:

  • Mintable
  • Auto Increment Ids
  • Burnable
  • Pausable
  • Enumerable
  • URI Storage
  • Ownable

The FootballPlayer contract has been updated to:

// SPDX-License-Identifier: MIT
// Compatible with OpenZeppelin Contracts ^5.0.0
pragma solidity ^0.8.24;

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

contract FootballPlayers is ERC721, ERC721Enumerable, ERC721URIStorage, ERC721Pausable, Ownable, ERC721Burnable {
uint16 private TOKEN_CAP = 4;
uint256 private _nextTokenId;

constructor()
ERC721("Football Players", "FTP")
Ownable(msg.sender)
{}

function pause() public onlyOwner {
_pause();
}

function unpause() public onlyOwner {
_unpause();
}

function safeMint(address to, string memory uri) public onlyOwner {
require (_nextTokenId <= TOKEN_CAP, "Token cap exceeded");
uint256 tokenId = _nextTokenId++;
_safeMint(to, tokenId);
_setTokenURI(tokenId, uri);
}

// The following functions are overrides required by Solidity.

function _update(address to, uint256 tokenId, address auth)
internal
override(ERC721, ERC721Enumerable, ERC721Pausable)
returns (address)
{
return super._update(to, tokenId, auth);
}

function _increaseBalance(address account, uint128 value)
internal
override(ERC721, ERC721Enumerable)
{
super._increaseBalance(account, value);
}

function tokenURI(uint256 tokenId)
public
view
override(ERC721, ERC721URIStorage)
returns (string memory)
{
return super.tokenURI(tokenId);
}

function supportsInterface(bytes4 interfaceId)
public
view
override(ERC721, ERC721Enumerable, ERC721URIStorage)
returns (bool)
{
return super.supportsInterface(interfaceId);
}
}

As we can see the safeMint function call the _setTokenURI method of the ERC721URIStorage extension, which for each token ID will store the corresponding URI in a mapping. This makes our life easier but of course will increase the cost of the gas fees. You can check the code here.

In this case, we don’t need to upload a folder to IPFS containing the JSON metadata of our tokens. Instead, we need to upload the JSON metadata individually to IPFS and pass the IPFS URL related to each token to the safeMint function when minting the token. For example, for token #0, which is the Italian player, we need to upload the file “0.json” to IPFS and copy the URL, which is: ipfs://bafkreibawu4weyt4dushenrzlodw54bullsixijkem55ybmudt4nwclvae

We then pass this URL as an argument to the safeMint function along with the address of the receiver. Obviously, the process is the same for all other NFTs.

The ERC721Enumerable contract adds enumerability
of all the token ids in the contract as well as all token ids owned by each account. For example it introduces the method totalSupply() that returns the total supply of tokens stored by the contract. You can find the code here.

Another interesting extension is the ERC721Burnable, which allow us to destroy tokens using the burn method. You can find the code here.

Furthermore we have the ERC721Pausable extension, which allow us to pause and unpause the contract using the namesake methods. This is useful in case of bugs or issues with the contract. The code of this extension can be found here.

To test this new contract, we need to modify the previous test to accommodate the change in the safeMint function. Additionally, we can add two more tests for the totalSupply and the burn method:

import { expect } from "chai";
import { ethers } from "hardhat";
import { HardhatEthersSigner } from "@nomicfoundation/hardhat-ethers/signers";
import { FootballPlayers} from "../typechain-types";

describe("FootballPlayers", () => {
let footballPlayers: FootballPlayers;
let owner: HardhatEthersSigner;
let addr1: HardhatEthersSigner;
let addr2: HardhatEthersSigner;

beforeEach(async () => {
footballPlayers = await ethers.deployContract("FootballPlayers");
[owner, addr1, addr2] = await ethers.getSigners();
});

describe("Deployment", () => {
it("Should set the right owner", async () => {
expect(await footballPlayers.owner()).to.equal(owner.address);
});

it("Should assign the contract name and symbol correctly", async () => {
expect(await footballPlayers.name()).to.equal("Football Players");
expect(await footballPlayers.symbol()).to.equal("FTP");
});
});

describe("Minting", () => {
it("Should mint a new token and assign it to owner", async () => {
await footballPlayers.safeMint(owner.address, "an IPFS address");
expect(await footballPlayers.ownerOf(0)).to.equal(owner.address);
});

it("Should fail if non-owner tries to mint", async () => {
await expect(footballPlayers.connect(addr1).safeMint(addr1.address, "an IPFS address"))
.to.be.revertedWithCustomError(footballPlayers, "OwnableUnauthorizedAccount");
});

it("Should auto increment tokenId with each new minted token", async () => {
await footballPlayers.safeMint(await owner.getAddress(), "an IPFS address");
expect(await footballPlayers.ownerOf(0)).to.equal(await owner.getAddress());

await footballPlayers.safeMint(await owner.getAddress(), "an IPFS address");
expect(await footballPlayers.ownerOf(1)).to.equal(await owner.getAddress());
});

it("Should not mint more than TOKEN_CAP tokens", async () => {
const TOKEN_CAP = 4;
for (let i = 0; i <= TOKEN_CAP; i++) {
await footballPlayers.safeMint(owner.address, "an IPFS address");
}
await expect(footballPlayers.safeMint(owner.address, "an IPFS address")).to.be.revertedWith("Token cap exceeded");
});

it("Should return the right tokenURI", async () => {
await footballPlayers.safeMint(owner.address, "an IPFS address");
expect(await footballPlayers.tokenURI(0)).to.equal("an IPFS address");
});
});

describe("Token transferring", () => {
it("Should transfer a token from one address to another", async () => {
await footballPlayers.safeMint(await owner.getAddress(), "an IPFS address");
await footballPlayers["safeTransferFrom(address,address,uint256)"](owner, addr1, 0);
expect(await footballPlayers.ownerOf(0)).to.equal(await addr1.getAddress());
});

it("Should revert when trying to transfer a token that is not owned", async () => {
await footballPlayers.safeMint(await owner.getAddress(), "an IPFS address");
await expect(
footballPlayers.connect(addr1)["safeTransferFrom(address,address,uint256)"] (addr1, addr2, 0)
).to.be.revertedWithCustomError(footballPlayers, "ERC721InsufficientApproval");
});

it("Should not send tokens to the 0 address", async () => {
await footballPlayers.safeMint(await owner.getAddress(), "an IPFS address");
await expect(
footballPlayers["safeTransferFrom(address,address,uint256)"] (owner, ethers.ZeroAddress, 0)
).to.be.revertedWithCustomError(footballPlayers, "ERC721InvalidReceiver");
});
});

describe("Enumeration", () => {
it("Should return the right total supply of tokens", async () => {
await footballPlayers.safeMint(owner.address, "an IPFS address");
await footballPlayers.safeMint(owner.address, "an IPFS address");
expect(await footballPlayers.totalSupply()).to.equal(2);
});
});

describe("Burning", () => {
it("Should correctly burn a token", async () => {
await footballPlayers.safeMint(owner.address, "an IPFS address");
await footballPlayers.burn(0);
expect(await footballPlayers.totalSupply()).to.equal(0);
await expect(footballPlayers.ownerOf(0)).to.be.revertedWithCustomError(footballPlayers, "ERC721NonexistentToken");
});
});
// Add more tests for other functionalities of your contract.
});

The complete code can be found here. We also deployed the new contract on Sepolia at the address 0x9BC38536FE4deAB5d2E4f8495841D90Fa8ec4cB2. Similarly to before, we minted the 5 NFTs. The only difference is that this time, in the Hardhat console, the NFTs should be minted using the following method:

await footballPlayersContract.safeMint(wallet.address, 'ipfs://bafkreibawu4weyt4dushenrzlodw54bullsixijkem55ybmudt4nwclvae'); 
await footballPlayersContract.safeMint(wallet.address, 'ipfs://bafkreigdl5o3mlhp7aipceegnt7vfvdk35pkxcl5l4mkqfrcxrgsdxbas4');
await footballPlayersContract.safeMint(wallet.address, 'ipfs://bafkreihrzyfjrrqmlqdxcdpu2bm55vn3ry3tipwzzj6w5f63wrgfg7zxdy');
await footballPlayersContract.safeMint(wallet.address, 'ipfs://bafkreiak3xhw6xxby4w3ugbiycsvuvbwaiqp4mhaxctzub6uk6v7zqtmta');
await footballPlayersContract.safeMint(wallet.address, 'ipfs://bafkreiabgc5blwvklnrqrd3dnv6zkxdbse6xexof4fe46kginotim7ad24');

The final collection is identical to the one we have obtained before and can be seen at Opensea.

Further Exploration

For those eager to dive into coding Solidity smart contracts, I recommend exploring the following resources:

Conclusions

I trust this article has been a valuable resource in your journey into NFT development with Hardhat on Ethereum. Should you have any questions, don’t hesitate to reach out. Happy coding!

Resources

--

--