Non-Fungible Tokens
In this course, we'll be looking at exactly what an NFT is, what it's used for, what it could be used for, and how to create one.
What is an NFT?
An NFT is shorthand for Non-Fungible Token. A non-fungible token is a unit of data stored on a blockchain that certifies a digital asset to be unique and authentic. It is not interchangeable. This is in contrast to cryptocurrencies like bitcoin, and many network or utility tokens that are fungible in nature.
What is an NFT used for?
An NFT is used to store data that certifies a digital asset to be unique and authentic. The assets can range from digital art, to music, to videos, to in-game items, to real estate, and more.
What could an NFT be used for?
An NFT could be used for anything that needs to be certified as unique and authentic. For example, it could be used to certify the authenticity of a piece of art, or it could be used to certify the authenticity of a real estate property.
As an interesting example, you could purchase a real piece of art from an art gallery across the world by purchasing the authentic NFT for it and then whenever you want, you can exchange the NFT for the real piece of art that can be shipped to you. This would allow you to purchase art from anywhere in the world without having to travel to the art gallery.
How do you create an NFT?
This course assumes you have set up the development environment as outlined in the Smart Contracts Beginner course. If you haven't, it's recommended that you do so before continuing.
Setting up our project
Before we start, we need to create a few files.
In the contracts folder:
- Create a new file called
ERC721.sol
.
In the test folder:
- Create a new file called
ERC721.ts
.
Writing our smart contract
In the ERC721.sol
file, add the following code and read the comments:
1// SPDX-License-Identifier: MIT 2pragma solidity ^0.8.18; 3 4import "@openzeppelin/contracts/token/ERC721/extensions/ERC721Enumerable.sol"; 5import "@openzeppelin/contracts/access/Ownable.sol"; 6 7contract ERC721Token is ERC721Enumerable, Ownable { 8 using Strings for uint256; 9 10 string private _baseTokenURI; 11 12 /** 13 * The token contstructor 14 * @param name Our token name 15 * @param symbol Our token symbol 16 * @param baseURI The base token URI 17 * @dev The token URI is appended with the token ID to form the full token URI 18 * For example: 19 * - tokenURI = https://mytoken.com/api/ 20 * - token ID = 1 21 * - token's URI = https://mytoken.com/api/1 22 */ 23 constructor( 24 string memory name, 25 string memory symbol, 26 string memory baseURI 27 ) ERC721(name, symbol) { 28 _baseTokenURI = baseURI; 29 mintNFT(msg.sender); 30 } 31 32 /** 33 * Mints a new NFT 34 * @param recipient The address of the recipient 35 * @return The new token ID 36 */ 37 function mintNFT(address recipient) public onlyOwner returns (uint256) { 38 uint256 newItemId = totalSupply(); 39 _safeMint(recipient, newItemId); 40 return newItemId; 41 } 42 43 /** 44 * Returns the base token URI 45 * In the base ERC721 contract, this function returns an empty string. 46 * We override it to return our own base token URI. 47 */ 48 function _baseURI() internal view override returns (string memory) { 49 return _baseTokenURI; 50 } 51 52 /** 53 * We are overriding this function from the base ERC721 contract. 54 * We want to append .json to the token URI since we'll be storing our token metadata as JSON files. 55 * @dev See {IERC721Metadata-tokenURI}. 56 */ 57 function tokenURI(uint256 tokenId) public view virtual override returns (string memory) { 58 _requireMinted(tokenId); 59 60 string memory baseURI = _baseURI(); 61 return bytes(baseURI).length > 0 ? string(abi.encodePacked(baseURI, tokenId.toString(), ".json")) : ""; 62 } 63}
Aside from the comments, we have to look at the contracts we are inheriting, namely ERC721Enumerable
and Ownable
.
-
ERC721Enumerable
is a contract that inherits fromERC721
. It adds the ability to enumerate tokens (i.e. it implements the ability to enumarate all of the token ids as well as token ids owned by each account). -
Ownable
is a contract that provides basic authorization control functions. It assigns anowner
to the contract and provides anonlyOwner
modifier that can be applied to functions to restrict their use to the owner.
Modifiers are functions that will be run before the function it is assigned to. It's typical to create modifier functions to make sure some specific conditions are met before running the function it is assigned to. For example, in our contract, we have the
onlyOwner
modifier. This modifier will check if the sender of the message is the owner of the contract. If it is, it will run the function it is assigned to. If it isn't, it will throw an error.
Writing our tests
In the ERC721.ts
file, add the following code:
1import { loadFixture } from "@nomicfoundation/hardhat-network-helpers"; 2import { expect } from "chai"; 3import { ethers } from "hardhat"; 4 5describe("ERC721", function () { 6 async function deployERC721Fixture() { 7 const [owner, account_1] = await ethers.getSigners(); 8 const ERC721Token = await ethers.getContractFactory("ERC721Token"); 9 const erc721Token = await ERC721Token.deploy( 10 "My NFT", 11 "MNFT", 12 "https://example.com/" 13 ); 14 return { erc721Token, owner, account_1 }; 15 } 16 17 describe("Deployment Tests", function () { 18 it("Should set the right owner", async function () { 19 const { erc721Token, owner } = await loadFixture(deployERC721Fixture); 20 expect(await erc721Token.owner()).to.equal(owner.address); 21 }); 22 23 it("Should mint a new token with the correct URI", async function () { 24 const { erc721Token, owner } = await loadFixture(deployERC721Fixture); 25 expect(await erc721Token.balanceOf(owner.address)).to.equal(1); 26 expect(await erc721Token.tokenURI(0)).to.equal( 27 "https://example.com/0.json" 28 ); 29 }); 30 }); 31});
So let's look at the code piece by piece:
1async function deployERC721Fixture() { 2 const [owner, account_1] = await ethers.getSigners(); 3 const ERC721Token = await ethers.getContractFactory("ERC721Token"); 4 const erc721Token = await ERC721Token.deploy( 5 "My NFT", 6 "MNFT", 7 "https://example.com/" 8 ); 9 return { erc721Token, owner, account_1 }; 10}
This is a function built on a nice feature provided by hardhat called fixtures.
Fixtures are functions that return a snapshot of the state of a contract. We can control what the state is when returned. In the code above, we are making sure that the contract is deployed with the correct parameters. We are also returning the owner and account_1 addresses.
1describe("Deployment Tests", function () { 2 it("Should set the right owner", async function () { 3 const { erc721Token, owner } = await loadFixture(deployERC721Fixture); 4 expect(await erc721Token.owner()).to.equal(owner.address); 5 }); 6 7 it("Should mint a new token with the correct URI", async function () { 8 const { erc721Token, owner } = await loadFixture(deployERC721Fixture); 9 expect(await erc721Token.balanceOf(owner.address)).to.equal(1); 10 expect(await erc721Token.tokenURI(0)).to.equal( 11 "https://example.com/0.json" 12 ); 13 }); 14});
This is where we write our tests. We are describing the tests we are writing and then writing the tests. Think of it as a sentence:
"Describing deployment tests, it should set the right owner. It should mint a new token with the correct URI."
So in our first block:
1it("Should set the right owner", async function () { 2 const { erc721Token, owner } = await loadFixture(deployERC721Fixture); 3 expect(await erc721Token.owner()).to.equal(owner.address); 4});
We are loading our fixture that ensures the contract has been deployed. We are expecting the owner of the contract to be equal to the owner address, because in our contract, we extended our contract with the Ownable
contract from OpenZeppelin:
1// Our contract 2... 3import "@openzeppelin/contracts/access/Ownable.sol"; 4 5contract ERC721Token is ERC721Enumerable, Ownable { 6 7 string private _baseTokenURI; 8 9... 10 11// Ownable contract 12/** 13* @dev Initializes the contract setting the deployer as the initial owner. 14*/ 15constructor() { 16 _transferOwnership(_msgSender()); 17}
This contract automatically assigns the sender of the message (the person who deployed the contract) as the owner of the contract.
In our second block:
1it("Should mint a new token with the correct URI", async function () { 2 const { erc721Token, owner } = await loadFixture(deployERC721Fixture); 3 expect(await erc721Token.balanceOf(owner.address)).to.equal(1); 4 expect(await erc721Token.tokenURI(0)).to.equal("https://example.com/0.json"); 5});
We are expecting the balance of the owner to be equal to 1, because in our contract constructor we minted a new token for the owner:
1constructor( 2 string memory name, // The token name 3 string memory symbol, // The token symbol 4 string memory baseURI // The base token URI 5) ERC721(name, symbol) { 6 _baseTokenURI = baseURI; // Here we set the base token URI 7 mintNFT(msg.sender); // *** Here we mint a new token for the owner *** 8}
We are also expecting the token URI of the token with the ID of 0 to be equal to https://example.com/0.json
.
And with that, you have successfully written your first test! Yay!
Compiling and running our smart contracts
To run our tests and make sure everything works, run the following command in your terminal:
1npx hardhat test
And with that, we have successfully created our NFT smart contract and written our tests!
All that is left is to deploy our NFT to the test network.
Which image will be used?
I have pre-deployed an image to IPFS that we will be using for our NFT. It will look like this:
The actual file that the NFT will reference has this structure:
1{ 2 "title": "Abstract 1", 3 "type": "object", 4 "properties": { 5 "name": { 6 "type": "string", 7 "description": "Abstract1" 8 }, 9 "description": { 10 "type": "string", 11 "description": "Abstract Art Test" 12 }, 13 "image": { 14 "type": "string", 15 "description": "ipfs://Qmb3LFLazGdxce9GWehYmEx1ct8wvXv92QPvk53kuR7St1" 16 } 17 } 18}
The structure looks like this to conform to the ERC721 Metadata JSON Schema.
If you're interested in deploying your own image, you can do so by uploading your json file to IPFS and then changing the base_uri
in the deploy.ts
file to your IPFS CID.
Deploying our smart contract
We will be deploying our smart contract on the Sepolia testnet.
In the deploy-erc721.ts
file, add the following code:
1// We require the Hardhat Runtime Environment explicitly here. This is optional 2// but useful for running the script in a standalone fashion through `node <script>`. 3// 4// You can also run a script with `npx hardhat run <script>`. If you do that, Hardhat 5// will compile your contracts, add the Hardhat Runtime Environment's members to the 6// global scope, and execute the script. 7import hre from "hardhat"; 8 9const config = { 10 owner_address: "your address here", 11 token_name: "My Awesome NFT", 12 token_symbol: "MANFT", 13 base_uri: "ipfs://QmfAJoYp518dvdRg87jMeTMtzzs7AzXS4XjasfhTTLcWyB/", 14}; 15 16async function main() { 17 const { owner_address, token_name, token_symbol, base_uri } = config; 18 19 console.log(`Deploying token ${token_name}. Owner: ${owner_address}...`); 20 21 const Token = await hre.ethers.getContractFactory("ERC721Token"); 22 const token = await Token.deploy( 23 owner_address, 24 token_name, 25 token_symbol, 26 base_uri 27 ); 28 29 console.log(`Deployed token. Owner: ${owner_address}`); 30 31 console.log("Waiting 1 minute for Etherscan to index the contract..."); 32 await new Promise((r) => setTimeout(r, 60000)); 33 34 console.log("Verifying contract..."); 35 await hre.run("verify:verify", { 36 address: token.getAddress(), 37 constructorArguments: [owner_address, token_name, token_symbol, base_uri], 38 }); 39 40 console.log("Contract verified! 🎉"); 41 console.log( 42 `Please find the verified contract on Etherscan: https://sepolia.etherscan.io/address/${token.getAddress()}` 43 ); 44} 45 46// We recommend this pattern to be able to use async/await everywhere 47// and properly handle errors. 48main().catch((error) => { 49 console.error(error); 50 process.exitCode = 1; 51});
And that's it! So without further ado, let's deploy our contract!
Run the following command in your terminal:
1npx hardhat run scripts/deploy-erc721.ts --network sepolia
If everything is set up correctly, you should see a message saying that you can find the verified contract on Etherscan with a link. Click the link and follow it there! You should see your contract deployed on the Sepolia testnet!
You should also be able to view your NFT in your wallet now!
Conclusion
In this course, you learned how to create a simple NFT smart contract using OpenZeppelin, how to write tests for your smart contracts, how to deploy your smart contract to a testnet, and how to verify your smart contract on Etherscan.
You're now ready to take a break and come back for the next NFT course where we will be looking at how to create a more complex NFT smart contract and how to create a dApp to interact with our NFT smart contract.