Creating Truly Decentralised NFTs — A Comprehensive Guide to ERC721 & IPFS
While NFTs are a great tool for uniquely identifying digital assets and ownership, storing data on the blockchain comes with a much higher cost relative to traditional databases (data has to be processed and replicated across nodes). Consequently, a common pattern which emerged in the NFT space was for the NFT to instead hold a link to the target asset rather than the actual asset itself. As such, absent a reliable decentralised storage alternative, many NFTs are actually still very reliant on traditional web infrastructure.
For example, a high-res image would be stored on a centralised server while only the link (i.e. https://www.domain.com/subdomain/imgLink) would be stored in the NFT. Those familiar with web2.0 architecture will be able to easily recognise that there is nothing stopping the image host from changing the image which the URL points to. Decentralised storage technologies such as IPFS (InterPlanetary File System) aims to solve exactly this by enabling such data to be securely replicated across multiple nodes while minimising the risk of being censored or tampered with.
Instead of addresses which are based on the location of a file, IPFS redefines the address based on the file content. While not in the scope of this guide, this definition is made possible through cryptographic hashing, Directed Acyclic Graphs, and Merkle Trees. From a data perspective, what the above enables is:
- Resilience: Data will always be available as long as even a single node hosts the data. This also makes it harder to censor.
- Reliability: As any change to the file itself will change the hashed address, there are less trust assumptions when navigating to an address. Users can always compute the hash themselves to verify authenticity. This also minimizes risk around version control.
- Deduplication: If some data forms part of multiple datasets, each dataset only needs to link to one version of the data. Consequently, nobody has to store the entire dataset as links can be used to pull sections of data from across the network.
I highly recommend checking out Proto.school if you want to wrap your head around all the concepts. For our purposes, IPFS enables the data our NFT is pointing to to be truly decentralised. To this end, I have compiled an end-to-end guide on how to integrate IPFS and ERC721 using Hardhat. This includes:
- Hardhat & IPFS Environment Setup & Configuration
- Extending OpenZeppelin’s ERC721 Contract & Helper Contracts
- Deploying Token Contract to Goerli Testnet (getting test ETH, connecting to node, private key exporting)
- Uploading Data to IPFS and Writing Response to the Blockchain
- Interacting Programatically with a Local Contract Instance
- Sending Created ERC721 Tokens on Goerli Testnet via Metamask
Although targeted at those starting out in the space, this guide requires some basic familiarity with Node.js and Solidity concepts. Of note, I have also skipped the testing section for this guide as the focus is on IPFS integration. All the code used in this guide can be found on github.
Related Guides:
Hardhat Setup
We first need to create a project directory and install Hardhat:
mkdir ERC721-IPFS
cd ERC721-IPFS
npm install --save-dev hardhat
Once the hardhat
package has been installed, we can then run npx hardhat
which will bring up some options for bootstrapping the project:
For this tutorial, we will select the Create a JavaScript project
option. You will be prompted with a series of questions which you can continuously select enter to. The last of which will install the project dependencies.
npm install --save-dev @nomicfoundation/hardhat-toolbox@^1.0.1
Following the npx hardhat
command, the project will have 3 folders:
contracts/
is where the source files for your contracts should be.test/
is where your tests should go.scripts/
is where simple automation scripts go.
In addition a hardhat.config.js
file will have been generated. This file will manage the plugins and dependencies viewable by hardhat. Plugins and dependencies will have to be installed first followed by requiring it in the hardhat.config.js
file.
The npx hardhat
command will have also created a sample Lock contract which we will not require for this guide so you can go ahead and remove them:
rm contracts/Lock.sol test/Lock.js
Now that Hardhat has been setup, we can begin creating our ERC721 token.
ERC721 Contract
OpenZeppelin, which spearheads the advancement of the ERC721 standard, provides a comprehensive library for secure smart contract development which we will be using to implement our token. To do this, we need to first install the OpenZeppelin contracts package.
npm install @openzeppelin/contracts
We can then import the OpenZeppelin contracts into our own contract by prefixing its path with @openzeppelin/contracts/...
.
Before starting our first ERC721 token contract, we need to complete the most important step: naming your token collection! This differs from the ERC20 implementation where the name is given to the token instead of a token collection. For ERC721 tokens which are non-fungible, each NFT has to have a different identifier but we can still group them together under the same collection. Hence, for ERC721 tokens, the smart contract represents the token collection where new NFTs from the collection can be minted by passing a unique identifier to the token collection contract.
The standard practice is to match the smart contract file name with that of your token collection. For this guide, I will be naming the token collectionDecentralisedNFT
(decentralised non-fungible token) with a symbol of DNFT
and hence my smart contract file name will be DecentralisedNFT.sol
. Feel free to choose your own collection name but do remember to replace any instance of DecentralisedNFT
with your own specially crafted name.
The new contract will be placed in the contracts/
folder:
touch contracts/DecentralisedNFT.sol
Open DecentraliseNFT.sol
in your code editor and add the following code. The code will utilise the Solidity’s constructor function to give the token collection a name
and symbol
upon deployment.
We will be importing OpenZeppelin’s ERC721URIStorage
via the path @openzeppelin/contracts/token/ERC721/ERC721.sol
. This contract implements additional functions on top of the standard ERC721 which will help us to manage the NFT metadata. You can find the path for the contract via OpenZeppelin’s docs or their github.
As the core set of ERC721 contracts are unopinionated, we have also declared a custom mint function mintCollectionNFT()
in order to implement NFT minting for the collection using the ERC721 internal functions. This includes the standard _safeMint()
from ERC721
as well as _setTokenURI()
from ERC721URIStorage
.
Do note that we are also using the Ownable
contract to ensure that only the contractOwner
can mint NFTs. Additionally, the Counters
contract ensures a unique auto-incrementing ID for each NFT created. Save the DecentralisedNFT.sol
contract and we can now compile the completed contract.
Compiling The Contract
For the Ethereum Virtual Machine (EVM) to run our code, we need to compile our Solidity code into the EVM compatible bytecode. To ensure there are no versioning issues, we can specify a Solidity version in the provided hardhat.config.js
file.
To compile the DecentralisedNFT
contract, we can use Hardhat’s handy compile command.
npx hardhat compile
Hardhat will have compiled the contract into a newly created artifacts/
folder. Although we only wrote the code for DecentralisedNFT.sol
, the console shows that there were 13 compiled files. This is because of how Solidity handles the importing of @openzeppelin/contracts
and its dependencies.
Deploying The Contract Locally
Before deploying the contract to a public network, it is best practice to test the contract on a local blockchain first. Hardhat simplifies the process of setting this up by having an in-built local blockchain which can be easily run through a single line of code:
npx hardhat node
Run the above command in a separate command window.
The Hardhat network will print out the address as well as a list of locally generated accounts. Do take note that this local blockchain only stores the interactions until the console is closed hence the state is not preserved between runs. Additionally, take the time to read through the details of the accounts as it is important that you do not use these sample accounts for sending any real money. With the local blockchain up and running, we can then start writing our deployment scripts. Hardhat currently does not have a native deployment system and hence the need for scripts.
For our deployment script, we will be making use of the hardhat-ethers
plugin. You can find the documentation for the plugin here. To use it in the project, we need to first install it:
npm install --save-dev @nomiclabs/hardhat-ethers ethers
Navigating to the scripts/
directory, you will be able to see that a deploy.js
file was previously created. We can replace the main()
function with the following:
Hardhat will deploy the contract using the first account created when we started up the node above. The mintCollectionNFT()
function in DecentralisedNFT.sol
will allow us to externally call the contract in order to mint DNFT
NFTs. To access the array of accounts created, we can use the getSigners()
helper function provided by the hardhat-ethers
plugin.
Save the file and we are now ready to deploy our DecentralisedNFT
contract!
npx hardhat run --network localhost scripts/deploy.js
As mentioned, we are deploying the contract to our localhost
. Do take note of the deployed contract address as we will need it in the next section for interacting with our contract programatically.
Configuring IPFS
Before we start minting NFTs, we must first setup your IPFS environment as this will allow the IPFS data to be stored on the blockchain. The easiest way to get started is to install IPFS Desktop which is a GUI to host and interact with the IPFS node. You can find the installation guide here.
Once installed, you will be able to see the IPFS instance running in your taskbar. By clicking on the Advanced
arrow, you should also see the connection options for your IPFS node. Note the default API address which we will be connecting to shortly.
Interacting With The Contract (with IPFS)
With our IPFS node setup, we can now proceed to creating a script to interact with our DecentralisedNFT
smart contract. The full script can be found here.
We will be relying on the following dependencies for our script and hence will need to install it into our working folder:
npm install --save-dev ipfs-http-client
npm install --save-dev it-to-buffer
In order to keep track of all the linked data, we will also create an imagesSummary
array which will hold objects representing a specific NFT/image. This includes the following data:
- imageCID: The identifier representing the image uploaded to IPFS
- metadataCID: The identifier representing the image metadata uploaded to IPFS. This includes the name, description, as well as the imageCID
- mintedNFTId: The token ID for the newly minted NFT which contains the NFT metadata link/URI
- metadataURI: The IPFS link where the NFT metadata is stored
- metadataJSON: The metadata object that is stored by IPFS
The imagesSummary
array will be added to whenever new information on the NFT is generated. The script will also log the imagesSummary
array at the end. As a preview, below is a screenshot of the imagesSummary
generated when running the code locally:
Getting the deployed contract
In order to get the instance of our deployed contract, you will need the contract address returned from the deployment command. You can replace the contractAddress
in the code below with the one provided by your local machine.
With that, we have set up our decentralisedNFT
object which is an abstraction of a contract deployed on the Ethereum network, in this case a local network. This decentralisedNFT
object enables a simple way to serialize calls and transactions to an on-chain contract and deserialize their result logs. You can refer to the ethers documentation for more info.
Configure the local IPFS node object
To interact with our local IPFS node programatically, we will need to create an IPFS instance to interact with. As mentioned in the above section, we will be using the default IPFS configuration.
Importing images
For this guide, we will be uploading images to IPFS and as such, we will need a folder to store all the images to be uploaded.
mkdir images
You can drag and drop the images to upload into the newly created folder and we should be good to go. In the interest of simplicity, do note that the code filters the folder for .png
images.
Running the script, the images should now be imported as buffered data objects which you can then handle in your code.
Uploading images to IPFS
We can then upload the image buffered data to IPFS via our locally configured IPFS instance. In order to do this, we use the ipfs.add
function which will return us the CID (content identifier) of the uploaded image.
Uploading metadata to IPFS
Once we have obtained the image CID, we can then add additional metadata to the link. For this guide, we will be implementing a simplified version of the recommended metadata JSON schema per the EIP-721 specification.
We will create a helper function to create our metadata object.
Note that for simplicity, I have decided to keep the same name
and description
for all the images but you could implement your own form of input to change this accordingly.
Likewise to the uploading of images, we will also receive a CID when the metadata is uploaded to IPFS.
Mint NFTs with metadata stored on-chain
Now that we have the metadata CID (which includes the image CID), we can then mint a NFT for each image which includes it’s metadata. For this, we will be making use of the custom mintCollectionNFT()
function which we implemented in our DecentralisedNFT.sol
contract. This function utilises the ERC721URIStorage
contract to store additional NFT metadata on the blockchain.
Additionally, we are also listening for any Transfer
event which is emitted upon a NFT mint in order to get the tokenId
created by the Counters
contract. Do note that the code here assumes that there are no concurrent transfers for the NFT. For simplicity, I have left out more complex event querying (ie. filter for transfers originating from the zero address).
Get the on-chain stored data
With the tokenId
, we can then query the blockchain for the stored data using the tokenURI()
function from the ERC721URIStorage
contract.
This call should return the URI prefixed with ipfs://
to indicate that the hash is a IPFS content identifier.
Get the IPFS stored data
Given the URI obtained from the blockchain, we can then query IPFS for the relevant metadata.
If all goes well, you should expect to see the JSON metadata which was created for each NFT.
Deploying the Contract Publicly
Given the length of this guide, I have opted to skip writing automated tests but this is definitely something which should be done before deploying your code into production. With that disclaimer out of the way, it’s finally time to deploy DecentralisedNFT
onto a public network! This is the exciting part, as anybody connected to the network will be able to interact with your NFT collection.
For the purpose of this guide, we will be deploying the contract onto the Goerli testnet. The Goerli testnet functions identical to the mainnet minus the dollar value attached to ETH. As such, it is the perfect environment to experiment with your contracts prior to deploying to the mainnet. Do note that we are also deploying to Goerli as many of the other testnets (Ropsten, Rinkeby, Kiln) will be stopped following ETH merge.
In order to deploy to Goerli, we will need to first source some Goerli ETH, hereon referred to just ETH. This is required as transactions on the public network need to be processed by a miner which demands a gas fee to be paid. So far, we have been testing the contract on our local network where all the accounts were locally generated with a set amount of ETH. On public networks, all the new ETH generated are minted to the miners and hence we need to source ETH in order to run our contract code.
The easiest way to procure some ETH is via faucets. Faucets are community funded websites where users can request for ETH to be sent to a privately owned wallet. Do note, it is best to send these funds to a separate dev wallet as the private keys will be needed in plain text later. Faucets are used in the test environment as a way to circulate testnet ETH for developers to use. You can easily find such sites by doing a search but I have linked 2 such sites below:
- goerlifaucet.com: Requires an Alchemy account
- goerli-faucet.pk910.de: Uses computing power to mitigate spam
Once you’ve got some ETH, let’s go ahead and add the network to our hardhat.config.js
so that Hardhat knows where to deploy to.
In order for Hardhat to deploy the contract to Goerli, it needs two additional things:
- A Goerli node which it can connect to in order to send the transaction to the network
- The wallet which will be used for deploying the contract
For the API to connect to the node, you can generate one by signing up on alchemy.io, creating a new app and copying the API KEY under the “VIEW KEY” on the app dashboard. Do note, you only need to sign up for the free account so you can just skip the payment information.
Next up, we will need the private key of the account that is going to pay for the deployment of the contract. In order to get the private key for the account which received the faucet ETH, you can export it directly from Metamask under the “Account details”. Copy and paste that private key into the config file.
With that, we are now ready to to deploy the contract by using the following command:
npx hardhat run scripts/deploy.js --network goerli
You would have noticed that, in contrast to our local environment, transaction confirmation was not instant and this is because the transaction had to be processed by the Goerli testnet. This gives you a taste of how time-consuming the dev cycle can be if not for a local environment. Also note that the ETH balances in the specified account has decreased by a small amount due to gas fees.
Once deployed, your console should output the newly deployed contract address:
Remember that the DecentralisedNFT
contract requires us to mint NFTs from the collection after the contract has been deployed. We can reuse the interact.js
script which we ran in the previous section for this. In order for interact.js
to interact with our Goerli deployed contract, we just need to change the contractAddress
to the address above, save and Hardhat will do the rest of the heavy-lifting. We can now run the interact.js
script on the Goerli testnet by running the following command:
npx hardhat run --network goerli scripts/interact.js
This will then return all the following logs:
As part of our script, we have minted 3 NFTs to the testnet, each containing the metadata for the image. As the metadata is an IPFS address, we can also paste the data into a IPFS enabled browser to get the IPFS stored data. Do note that the data is being distributed from your local IPFS node hence your node needs to be online for the link to work. For a shameless self-plug example, below is the data for tokenId
3 which was minted:
And pasting the image URI into the same browser:
That’s a one-of-one screenshot of my Twitter account: https://twitter.com/kai27_crypto! Maybe one-day it will be worth millions. Follow for more crypto related analysis & guides ;)
Jokes aside, that covers the IPFS side of things but we still need to view our token to validate that it is really on-chain. We can do this by adding our token contract to Metamask. Open Metamask and navigate to the assets tab where you will see an “Import tokens” link. Select the link and enter the contract address, the “Token Symbol” field should be auto-populated but you will need to enter 0
for the decimal field.
Once imported, you should be able to see the 3 DNFT
in your wallet.
You might have noticed that unlike the ERC20 implementation, the send button for our ERC721 NFT has been disabled. Unfortunately, Metamask desktop extension does not currently support additional NFT functionalities. In order to see our NFT being transferred, we can either interact with our contract via NFT wallets which support such functions or create another script to interact programmatically. As an example of the latter, transfer.js
showcases a simple transfer of an NFT from the collection.
npx hardhat run --network goerli scripts/transfer.js
Running the above, NFT 1
has been sent to my other dev wallet:
All these transactions are publicly viewable hence you can use tools such as Etherscan to view and analyse transactions. For example, the transaction which I just sent can be found here:
For NFTs, you can also view all the NFTs in the collection by using the token tracker which can be accessed from the main contract overview page.
Congrats, you have successfully created your very own ERC721 NFT which links to an image stored on IPFS! Remember that if the image on IPFS changes by even a single pixel, the CID will also change hence nullifying the authenticity of the NFT. This framework is not limited to just image data and hence paves the way forward to decentralised ownership for any kind of data!
Thanks for staying till the end. Would love to hear your thought/comments so do drop a comment. I’m active on twitter @AwKaiShin if you would like to receive more digestible tidbits of crypto-related info or visit my personal website if you would like my services :)