How To Build Layer 2 NFTs With Polygon and IPFS

A Faster, More Efficient Way To Create NFTs

Justin Hunter
Pinata
13 min readMay 20, 2021

--

The gas fees on Ethereum are currently astronomical. It makes sense that layer 2 solutions are seeing a boom in usage. However, it’s important to ensure NFTs that start their lives on a layer 2 solution are created in a way that makes them portable and verifiable.

One of the most known and most-used layer 2 solutions in the Ethereum space is Polygon (formerly Matic). Polygon enables smart contract deployment and transactions with significant performance and cost benefits. When it’s time to move assets to and from the main Ethereum chain, Polygon makes this easy.

Today, we’re going to build a contract that will mint NFTs that are backed by IPFS. Let’s get started! You’re going to need a code editor, NodeJS installed, and a little bit of dedication.

The Setup

We’re going to be using Hardhat to handle our project configuration and deployment. Hardhat, like Truffle, provides a suite of tools that help make development on Ethereum easier. Because Polygon is an Etherem EVM compatible layer 2, we can use Hardhat with Polygon in virtually the same way we would Ethereum.

Open up the terminal app on your computer, and create a new project folder. We’ll call it polygon-nfts.

mkdir polygon-nfts

Change into that directory, and let’s initialize a new Hardhat sample project to get us started. From the root of your polygon-nfts directory, run the following commands:

npm install --save-dev hardhat && npx hardhat

The second of these commands will walk you through some options. Choose the sample project options and then accept all the default options presented after that. Once that process is complete, you’ll have some new files in your project folder. Open the entire project folder up in the code editor of your choice, and let’s take a look at what we have.

You should have a directory that looks like this:

contracts
-Greeter.sol
scripts
-sample-script.js
test
-sample-test.js
.gitignore
hardhat.config.js
package-lock.json
package.json

Because we decided to use a sample project, we have three folders that we otherwise wouldn’t if we were starting with a blank project:

  • contracts
  • scripts
  • test

The contracts folder has an example Greeter.sol file. The scripts folder has a simple script file that gets the code for the contract and deploys it. Pretty simple. Our test file currently tests that our contract can be deployed and can be called. Pretty simple stuff.

We’re not going to use much of this, but it’s a great starting point as we understand how to plug Polygon into the mix.

Setting Up Polygon

We’re going to need to get some MATIC tokens in our wallet in order to do anything. So, the first step is to make sure you have an Ethereum wallet. Simply go to Metamask’s website, install the extension and follow the process for creating a new wallet. Of course, if you already have a wallet, you can skip this step.

Once you have created your wallet, you’ll need to get your private key. This key will be necessary for deploying your NFT contract and interacting with the layer 2 chain. In the Metamask extension, you can export your private key by clicking on the three-dot menu icon next to your wallet account:

Choose Account Details, and there you’ll be able to export your private key. When you have the private key, you’re going to do two things:

  1. Create a file called .env at the root of your polygon-nft project and add this to it PRIVATE_KEY=YOUR_EXPORTED_PRIVATE_KEY. Replace YOUR_EXPORTED_PRIVATE_KEY with your actual private key.
  2. In your project’s .gitignore file add a line that simply says .env. This will ensure you don’t accidentally commit the .env file to source control and expose your key.

Now, because we are using an environment variable file, we’re going to need to install another dependency in our project to make it easier to work with these variables. Run the following command from the root of your project directory:

npm i dotenv

Now, let’s get some MATIC tokens from the testnet. Head over to the testnet faucet here. You’ll paste your wallet address in the provided field, select MATIC token, and select the network as Mumbai.

When you submit, you’ll be asked to confirm. Do that, and you will shortly have MATIC tokens available in your wallet.

Ok, now let’s update the hardhat.config.js file so that it knows we’re using the Polygon network. At the top of that file above the first requirestatement, add:

require('dotenv').config();
const PRIVATE_KEY = process.env.PRIVATE_KEY;

This is telling that file that we will use the dotenv package to pull in our environment variables. The second line is creating a variable that references the environment variable we stored in .env for our private key.

Next, you’ll need to find the module.exports object in the file. It will likely only contain a definition of the Solidity version. Let’s replace all of that module.exports code with this:

module.exports = {
defaultNetwork: "matic",
networks: {
hardhat: {
},
matic: {
url: "https://rpc-mumbai.maticvigil.com",
accounts: [PRIVATE_KEY]
}
},
solidity: {
version: "0.8.0",
settings: {
optimizer: {
enabled: true,
runs: 200
}
}
},
paths: {
sources: "./contracts",
tests: "./test",
cache: "./cache",
artifacts: "./artifacts"
},
mocha: {
timeout: 20000
}
}

It looks like a lot is going on in here, but it’s really not as bad as it seems. We are telling Hardhat that we’re using Polygon, where to find the RPC address (the API that talks to the network), and our private key. Outside of that, there’s just some configuration that isn’t especially important for you to understand.

Before we do anything else, it’d be good to test our configuration. Let’s deploy our sample Greeter.sol contract to the Polygon testnet. Run the following:

npx hardhat run scripts/sample-script.js --network matic

This is pointing Hardhat to the script to run and it’s specifying the network to use. We defined the matic network in the hardhat.config.js. After a few moments, you should see something like this outputted in your terminal:

Compiling 2 files with 0.7.3Compilation finished successfullyGreeter deployed to: 0x790a1c9a212A13Fce5C1cfA4904f18bD3540E1e8

Your contract address will be different than mine, but if you see something like this, it means that the configuration file is working as expected.

We didn’t end up running our sample test because we didn’t need to. We don’t really need to test the smart contract since we’ll be replacing that entirely. What we needed to test what that our hardhat.config.js file was working as expected.

And it is!

The Contract

We’re going to use Open Zeppelin to help us get started on our contract. Our NFT contract will be ERC-721 based. So, we need to install Open Zeppelin’s contract library. From your project directory in the terminal, run:

npm install @openzeppelin/contracts

Once that’s installed, we can start building our contract. Open the contracts folder and create a new file within it called NFT.sol (or whatever you want to call it as long as it has a .sol extension. This contract is going to be very simple, so I’ll drop it below and walk you through each line:

pragma solidity ^0.8.0;import "@openzeppelin/contracts/token/ERC721/ERC721.sol";
import "@openzeppelin/contracts/utils/Counters.sol";
import "@openzeppelin/contracts/access/Ownable.sol";
contract MyNFT is ERC721, Ownable {
using Counters for Counters.Counter;
using Strings for uint256;
Counters.Counter private _tokenIds;
mapping (uint256 => string) private _tokenURIs;

constructor() ERC721("MyNFT", "MNFT") {}
function _setTokenURI(uint256 tokenId, string memory _tokenURI)
internal
virtual
{
_tokenURIs[tokenId] = _tokenURI;
}
function tokenURI(uint256 tokenId)
public
view
virtual
override
returns (string memory)
{
require(_exists(tokenId), "ERC721Metadata: URI query for nonexistent token");
string memory _tokenURI = _tokenURIs[tokenId];
return _tokenURI;
}
function mint(address recipient, string memory uri)
public
returns (uint256)
{
_tokenIds.increment();
uint256 newItemId = _tokenIds.current();
_mint(recipient, newItemId);
_setTokenURI(newItemId, uri);
return newItemId;
}
}

Since we are leveraging Open Zeppelin’s ERC721 contract, there’s less code for us to write. Let’s run through this.

The first line is defining the version of Solidity we’re using. I like to live dangerously, so we are using the most recent version (at the time of writing this).

The next three lines are bringing in the Open Zeppelin contracts we need, with the ERC-721 contract being the most important. Having these contracts written for us already, really cuts down on how long it takes to create smart contracts.

Next we are defining our contract class and giving it the very creative name of MyNFT. Our clas definition is extending the existing class definitions of the ERC721 contract and the Ownable contract provided to us by Open Zepelin.

After that, we are defining some variables. Counters and Strings are helpers for, well, counting and converting data to strings. After those, we define a variable to hold the next token ID. Finally, we have a mapping of token ID to token URI.

With all our variables defined and imports included, we can get to the meat of things. We have a constructor that is literally just defining the token name and symbol. Again, I’m being very creative here with my token name of MyNFT and symbol of MNFT.

As of the most recent Open Zeppelin update, their ERC-721 contracts no longer have a built-in setTokenURI function. The decision appears to be one designed around the idea of reducing deployment gas costs for users that do not need that function. We do need that function.

That’s OK, though. We can re-implement it. That’s what our first function is doing. The function _setTokenURI is taking in a token ID and a token URI and adding it to the mapping.

By doing this, we can now allow for the return of a token URI with our tokenURI function. This is important because the token URI points to all the metadata about the NFT. That means the name, the attributes, and the asset itself. We have to make sure that the URI is both stored and can be returned to anyone interested.

Finally, we have our mint function. It does exactly what it says. This function takes in a wallet address and a token URI. The token URI is used in the _setTokenURI function, the token ID is incremented. The new token is sent to the recipient's address.

That’s it. That’s the contract. Pretty simple, right? Now, we can test it.

Testing

We ran our first test of the configuration file directly against the Polygon Mumbai testnet. We’re not going to do that for our initial testing of our contract. Instead, we are going to use Hardhat’s in-memory network to simulate the blockchain and deployment.

To do this, we need to return to our hardhat.config.js file. We’re going to temporarily change the defaultNetwork from “matic” to “hardhat”. You’ll also need to comment out the this section of the config:

matic: {
url: "https://rpc-mumbai.maticvigil.com",
accounts: [PRIVATE_KEY]
}

Save the changes, then we’re almost ready to test.

We need to do two things. First, go ahead and find the artifacts folder at the root of your project directory. Inside that, delete the contracts folder. Next, find your main contracts folder at the root of your project and delete the Greeter.sol contract.

You should be left with your NFT.sol contract. We need to also update our test file. So, inside your test folder, open up the sample-test.js file. Replace everything inside with:

const { expect } = require("chai");describe("NFT", function() {
it("It should deploy the contract, mint a token, and resolve to the right URI", async function() {
const NFT = await ethers.getContractFactory("MyNFT");
const nft = await NFT.deploy();
const URI = "ipfs://QmWJBNeQAm9Rh4YaW8GFRnSgwa4dN889VKm9poc2DQPBkv";
await nft.deployed();
await nft.mint("0x7028f6756a9b815711bc2d37e8d5be23fdac846d", URI)
expect(await nft.tokenURI(1)).to.equal(URI)
});
});

In this test, we are getting our contract (remember we called it MyNFT) and deploying it. Then we are minting a token with a fake Ethereum address and a token URI. Notice the format of the URI. We want to provide our metadata via IPFS and Pinata, but we want that metadata to be resolved from any IPFS gateway, not just Pinata’s. Formatting the URI this way helps future proof the resolution of that metadata.

The URI in the above example is fake, but we’ll soon be creating a real one and minting an NFT on the Polygon network. But let’s make sure our contract looks good. To test it, in your terminal, run the following command:

npx hardhat test

You should see a result like this:

We can write a lot more tests like this, and we should. But I’ll leave that to you. The format of the test we wrote together should help guide you in writing additional tests.

I think we’re ready to mint a token on the Polygon Mumbai testnet.

Minting

Before we can mint an NFT, we need to create an asset and pin it to IPFS. I have an image I am going to use, but you can use any file you’d like. Here’s the image I will be using:

We will need to get this onto the IPFS network. We’ll use Pinata for this. Sign up for an account or log in here. Once you’ve signed up for an account, head over to the Pin Manager and upload your file. When you upload it, you’ll see it in the Pin Manager table:

You’ll need to click the copy button next to the IPFS CID you got for your file. Once you’ve copied it, you can paste it somewhere safe to use later.

We’ve uploaded the asset, but we need to upload a JSON file that represents the metadata for our NFT. This sounds difficult, but it’s actually pretty simple. We’ve written about this metadata here, but I’m going to show you how to do it in this post.

In a text editor (this can be notepad or any other simple text editor on your computer), create a new file. In that file, add the following:

{
"name": "My NFT",
"description": "This is my NFT",
"image": "ipfs://QmQRDYPTprDPknn3zE2ZSpc8DjnUKfYztyaeMBYdR8GDdw"
}

You can name your NFT whatever you’d like, and you can provide whatever description you’d like. The image property though needs to point to that CID you got back from the asset you uploaded previously. If you need to get this again, you can copy it from the Pin Manager. We want the asset to load from any IPFS gateway, so to future proof things, we are going to format our asset URI like this ipfs://YOUR_CID.

Once you’ve done this, save that file as metadata.json. Once you’ve saved, you can head back to the Pinata Pin Manager and upload that file. Again, when you do so, you’ll get an IPFS CID. Copy that because it’s what we will use when we mint our NFT.

There’s a whole additional tutorial’s worth of information on how to deploy your smart contract and then let people mint NFTs from within a web app you create. Maybe I’ll write that someday, but today, we’re going to deploy and mint from the command line. We’re going to modify the sample-script.js file from within our scripts folder to do this. In fact, let’s change the name of that file to deploy-script.js. Inside the file, replace everything with this:

const hre = require("hardhat");async function main() {
const NFT = await hre.ethers.getContractFactory("MyNFT");
const nft = await NFT.deploy();
await nft.deployed(); console.log("NFT deployed to:", nft.address);
}
main().then(() => process.exit(0)).catch(error => {
console.error(error);
process.exit(1);
});

To deploy this to the Mumbai testnet, we need to update our hardhat.config.js file one more time. Change the defaultNetwork back to “matic”. Now, uncomment the matic section that we commented out earlier.

In your terminal, run the following command:

npx hardhat run scripts/deploy-script.js --network matic

If all goes well, your NFT contract will now be minted. The output in your terminal will include the contract address. Copy that because we’ll need it to mint our first NFT.

To mint a token, we’re going to create a new script. Inside the scripts create a file called mint-script.js. Inside that file, add the following:

const hre = require("hardhat");async function main() {
const NFT = await hre.ethers.getContractFactory("MyNFT");
const URI = "ipfs://YOUR_METADATA_CID"
const WALLET_ADDRESS = "YOUR_WALLET_ADDRESS"
const CONTRACT_ADDRESS = "YOUR NFT CONTRACT ADDRESS"
const contract = NFT.attach(CONTRACT_ADDRESS); await contract.mint(WALLET_ADDRESS, URI);
console.log("NFT minted:", contract);
}
main().then(() => process.exit(0)).catch(error => {
console.error(error);
process.exit(1);
});

This is a pretty simple script. It takes the URI for the metadata we uploaded to Pinata, your Ethereum wallet address, and the contract address you got when you deployed the NFT contract.

Save that file, and you’re ready to mint! Run the following command in your terminal:

npx hardhat run scripts/mint-script.js --network matic

Assuming there are no errors, you just minted an NFT for the asset you created earlier. It’s in your wallet. To prove this, we can run one more script. Create a new file in the scripts folder called get-token-script.js. Inside that file add the following:

const hre = require("hardhat");async function main() {
const NFT = await hre.ethers.getContractFactory("MyNFT");
const CONTRACT_ADDRESS = "YOUR_CONTRACT_ADDRESS"
const contract = NFT.attach(CONTRACT_ADDRESS);
const owner = await contract.ownerOf(1); console.log("Owner:", owner); const uri = await contract.tokenURI(1); console.log("URI: ", uri);
}
main().then(() => process.exit(0)).catch(error => {
console.error(error);
process.exit(1);
});

This script is asking who the owner of the first token created with our NFT contract. We know we’ve only created one token. It also fetches the token URI for that token ID.

You can run this script with the following command:

npx hardhat run scripts/get-token-script.js --network matic

You’ll see something like this in the terminal:

Owner: 0xd7220ab26a887a60Af3a11178eF4A48BE8DncbA6URI:  ipfs://QmZu6UUMHo2bHLiRMZCoQf7hiSmnFVVzWqnEAyF9SJwxhx

Your owner address and URI will be different, but you get the idea.

You’ll also note how fast these transactions were. Creating the contract, minting a token, fetching info about the token–it was all very fast. That’s the power of Polygon. That and it didn’t cost much in gas. If you check your wallet’s balance here, you’ll see you didn’t spend much since getting your tokens from the faucet.

Wrapping Up

The next steps would be bridging your NFT between the Polygon network and the main Ethereum chain. Fortunately, Polygon makes this easy. This tutorial has gotten quite long, so we won’t cover that here, but the documentation is really great.

The beauty of what we just did is that we put our asset file on IPFS. No matter what blockchain the NFT lives on, we can rest assured that the asset will always resolve to the CID that was generated when we uploaded the file to IPFS through Pinata.

If you’d like to review the source code from this tutorial, you can find it here.

Happy pinning!

--

--

Justin Hunter
Pinata

Writer. Lead Product Manager, ClickUp. Tinkerer.