Crafting an ERC-721 NFT Contract with OpenZeppelin

mosamorphingmosamorphing
15 min read

OpenZeppelin is widely used in the Ethereum ecosystem because it provides secure, audited and gas-efficient implementation of common standards like ERC-20, ERC-1155 and ERC-721. It is possible to write contracts from these standards without the use of OpenZeppelin (in fact, it is good practice for beginners) but beyond security, it is also a safer and faster way to write contracts.

Today, we are:

  • building an ERC721 contract that stores the NFT onchain in SVG.

  • testing and deploying the contract above on testnet (in this case AmoyPolygon)

  • minting the NFT and successful display on Opensea testnet

Prerequisites

  • know how to write basic solidity smart contracts

  • A good understanding of NFTs and the ERC721 standard

  • understand how to write test and deployment scripts

  • understand how to verify contracts using hardhat

  • have a funded testnet account ready for deployment and for minting

Some Fundamentals

What is an ERC721 Contract?

ERC721 is an Ethereum token standard that allows for the creation of non-fungible tokens (NFTs). Unlike ERC20 tokens, which are fungible (interchangeable), ERC721 tokens are unique and indivisible. Each token has a distinct identifier, making it ideal for representing ownership of unique assets like digital art, collectibles, or in-game items.

Key Features of ERC721

  • Unique Tokens: Each token has a unique ID.

  • Ownership Tracking: Tracks the owner of each token.

  • Transferability: Tokens can be transferred between addresses.

  • Metadata: Tokens can have associated metadata (e.g., images, descriptions).

Use Cases for ERC721 Contracts

ERC721 contracts are versatile and have been used in a variety of applications:

  1. Digital Art: NFTs have become a popular way for artists to sell their work. Platforms like SuperRare and Foundation use ERC721 tokens to represent digital art.

  2. Collectibles: Projects like CryptoKitties and NBA Top Shot use ERC721 tokens to represent unique collectibles.

  3. Gaming: In-game items, characters, and assets can be tokenized as NFTs, allowing players to own and trade them.

  4. Virtual Real Estate: Platforms like Decentraland use ERC721 tokens to represent parcels of virtual land.

  5. Identity and Certification: NFTs can represent unique identities, certifications, or licenses.

Building an ERC721 contract that stores the NFT onchain in SVG.

Step 1: First principles and Imports

// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.28;

import "@openzeppelin/contracts/token/ERC721/ERC721.sol";
import "@openzeppelin/contracts/utils/Base64.sol";
  • First things first, a license and solidity version is declared. The ^ symbol means the contract is compatible with versions 0.8.28 and above, but not 0.9.0 or higher.

  • Secondly, we import OpenZeppelin’s ERC721 implementation by installing the package npm install @openzeppelin/contracts. By importing this contracts logic, we do not need to write the core ERC721 functionality from scratch because it is already implemented in the imported logic.

  • Then, we import the Base64 encoding utility. This is useful for encoding data into the NFT (such as the name and the description). This could also be written manually but importing this makes it readily available for implementation.

Step 2: Contract Declaration, State Variable and Constructor

contract Eden is ERC721 {
uint256 private _tokenIdCounter;

constructor() ERC721('Eden', 'EDEN') {}
  • Our contract name is declared Eden and is an ERC721 contract. This declaration automatically inherits all the functionalities of an ERC721 contract as they have already been imported. Some of these functionalities include token ownership tracking, transfer functions, and metadata handling.

  • _tokenIdCounter: is a private variable that allows the contract deployer track the number of times the NFT is minted. it starts at 0 and increments with each new mint because NFTs must have a unique token ID (they are 1-of-1) to prevent duplicates.

  • The constructor is only initialized once as the contract deploys and assigns the name Eden and symbol EDEN to this NFT collection. Without this, the contract wouldn’t have a name or symbol.

Step 3: Mint Function

function mint() public {
    _safeMint(msg.sender, _tokenIdCounter);
    _tokenIdCounter++;
}
  • a mint function is publicly declared to allow anyone mint a new NFT.

  • when anyone calls the mint function, the _safeMint creates a new NFT and assigns it to the caller (i.e msg.sender). it is also good to note that ordinarily, calling _mint(msg.sender) would also mint a new NFT but OpenZeppelin’s ERC721 contract defaults to _safeMint because it protects against minting to a contract that can’t handle ERC721 tokens. After it is minted, the _tokenIdCounter determines the token ID and increases by 1 (_tokenIdCounter++) for the newly minted NFT.

Step 4: Token URI function

function tokenURI(uint256 tokenId) public pure override returns (string memory) {
    require(tokenId == 0, 'Token ID not found');
  • This function takes a tokenId as an argument and returns the metadata (such as the name and description) of the NFT. since it does not modify or read state variables from the blockchain, it is marked as pure to optimize gas costs. Also, it overrides the tokenURI function from the ERC721 standard, ensuring that the contract remains fully compliant with OpenZeppelin’s ERC721 implementation.

  • The require statement ensures that only one NFT exists, the token with ID 0. If someone queries an invalid token ID (e.g., 1, 2, 3), the function reverts with the error message 'Token ID not found'. This effectively restricts the contract to a single unique NFT, making it a 1-of-1 collectible.

Step 5: SVG Image

string memory svg = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 800 600" width="800" height="600" style="background-color: #87CEEB;">'
'<!-- Sky -->'
'<rect width="800" height="600" fill="#87CEEB" />'
'<!-- Sun -->'
'<circle cx="700" cy="100" r="50" fill="#FFD700" />'
'<!-- Ground -->'
'<rect x="0" y="400" width="800" height="200" fill="#228B22" />'
'<!-- River -->'
'<path d="M 400 400 Q 450 350 500 400 T 600 400" fill="none" stroke="#1E90FF" stroke-width="20" />'
'<path d="M 400 400 Q 450 450 500 400 T 600 400" fill="none" stroke="#1E90FF" stroke-width="20" />'
'<!-- Trees -->'
'<g transform="translate(100, 300)">'
'<rect x="0" y="0" width="20" height="100" fill="#8B4513" />'
'<circle cx="10" cy="0" r="50" fill="#006400" />'
'</g>'
'<g transform="translate(300, 320)">'
'<rect x="0" y="0" width="20" height="80" fill="#8B4513" />'
'<circle cx="10" cy="0" r="40" fill="#006400" />'
'</g>'
'<g transform="translate(500, 310)">'
'<rect x="0" y="0" width="20" height="90" fill="#8B4513" />'
'<circle cx="10" cy="0" r="45" fill="#006400" />'
'</g>'
'<g transform="translate(700, 300)">'
'<rect x="0" y="0" width="20" height="100" fill="#8B4513" />'
'<circle cx="10" cy="0" r="50" fill="#006400" />'
'</g>'
'<!-- Adam and Eve -->'
'<g transform="translate(350, 450)">'
'<!-- Adam -->'
'<circle cx="0" cy="0" r="15" fill="#FFDAB9" />'
'<rect x="-5" y="15" width="10" height="30" fill="#0000FF" />'
'<!-- Eve -->'
'<circle cx="40" cy="0" r="15" fill="#FFDAB9" />'
'<rect x="35" y="15" width="10" height="30" fill="#FF69B4" />'
'</g>'
'<!-- Serpent -->'
'<path d="M 450 450 Q 470 430 490 450 T 530 450" fill="none" stroke="#32CD32" stroke-width="5" />'
'<circle cx="530" cy="450" r="5" fill="#32CD32" />'
'</svg>';
  • this is a string variable that contains a scalable vector graphics (SVG) image. the SVG depicts a representation of the garden of eden with a sky, sun, ground, river, trees, adam and eve, and a serpent.

Step 6: JSON Metadata

string memory json = Base64.encode(
    bytes(
        string(
            abi.encodePacked(
                '{"name": "Garden of Eden",',
                '"description": "A depiction of the Garden of Eden as an NFT.",',
                '"image": "data:image/svg+xml;base64,',
                Base64.encode(bytes(svg)),
                '"}'
            )
        )
    )
);
  • this is a variable that contains the json metadata for the NFT. the metadata includes:

    • name: The name of the NFT ("Garden of Eden").

    • description: A description of the NFT ("A depiction of the Garden of Eden as an NFT.").

    • image: The SVG image, encoded in Base64 and embedded directly in the JSON.

Step 7: Return URI

return string(abi.encodePacked('data:application/json;base64,', json));
  • Returns the JSON metadata as a data URI.

    • The metadata is encoded in Base64 and prefixed with data:application/json;base64,, which is the standard format for inline data URIs.

If the above code is written correctly, at the command of npx hardhat compile, a successful compilation message is thrown (just as seen below).

But we are not quite done yet.

Testing and deploying the contract above on testnet

Before deploying our ERC-721 contract on a testnet, it’s crucial to test its functionality to ensure it behaves as expected. Smart contract testing helps catch potential issues early, saving time and preventing costly mistakes on the blockchain. In this section, we’ll write tests to verify key functionalities like minting and retrieving metadata.

Once testing is complete, we’ll move on to deploying the contract to a testnet, making it accessible for real-world interactions. Let’s start by writing our test cases.

Testing the Eden Contract

Testing ensures that our Eden contract functions as expected before deploying it to a blockchain. Using Hardhat and Chai, we verify key functionalities such as token minting, ownership, and metadata retrieval.

Test Setup: Imports

const { expect } = require("chai");
const { ethers } = require("hardhat");
  • Chai (require("chai"))

    • Chai is an assertion library that provides functions like expect, assert, and should to validate test conditions.

    • expect is commonly used with Hardhat and ethers.js to check if smart contract behavior matches expectations.

  • Hardhat & Ethers (require("hardhat") and require("ethers"))

    • Hardhat: A development environment for compiling, deploying, testing, and debugging Ethereum smart contracts.

    • Ethers.js: A library that helps interact with Ethereum contracts and networks. It provides utilities to deploy contracts, send transactions, and call contract functions.

    • Hardhat integrates Ethers.js, allowing you to work with smart contracts in a local blockchain simulation.

Defining the test

describe("Eden Contract", function () {
  let Eden;
  let eden;
  let owner;
  let addr1;
  • describe organizes tests into a structured suite, declaring variables to store the contract and ethereum accounts.

  • Eden (uppercase) is a contract factory (picture it as a factory that mass produces similar contracts without building new ones from scratch).

  • eden (lowercase) is the deployed instance of the contract. after deployment, this is the live smart contract that tests will interact with.

  • owner: The deployer of the contract (usually the first account in the list). This account has special privileges, such as being the default admin.

  • addr1: A secondary test account that can be used to simulate interactions from another user, like making transactions or calling functions that require a different sender.

Deploying a fresh contract before each test

beforeEach(async function () {
  // Get the ContractFactory and Signers
  Eden = await ethers.getContractFactory("Eden");
  [owner, addr1] = await ethers.getSigners();

  // Deploy the contract
  eden = await Eden.deploy();
});

Remember when we said contract factory is a way of mass producing contracts? good. the above function is set up to deploy a new instance of Eden contract before each test runs. this ensures that tests runs on a fresh instance of the contract, preventing interference from previous test results.

  • ethers.getContractFactory("Eden"): Loads the compiled contract.

  • [owner, addr1] = await ethers.getSigners(): Retrieves test Ethereum accounts.

  • eden = await Eden.deploy();: Deploys the contract.

Test 1: Verifying Contract Deployment

describe("Deployment", function () {
  it("Should set the correct name and symbol", async function () {
    expect(await eden.name()).to.equal("Eden");
    expect(await eden.symbol()).to.equal("EDEN");
  });

this test is in place to check if the contract name and symbol are correctly set by calling eden.name() and eden.symbol()

Test 2: NFT Minting Test

it("Should mint a token with tokenId 0", async function () {
  // Mint a token
  await eden.mint();

  // Check the owner of tokenId 0
  expect(await eden.ownerOf(0)).to.equal(owner.address);
});

this test ensures that the contract properly mints a 1-of-1 NFT with token ID 0 by calling eden.mint() and also verifying that ownerOf(0) returns to the correct owner’s address.

Note: Without this check, we can’t be sure minting works correctly, which is the core function of an NFT contract.

Test 3: Validating Token Metadata

describe("Token URI", function () {
  it("Should return the correct tokenURI for tokenId 0", async function () {
    // Mint a token
    await eden.mint();

this ensures that calling tokenURI(0) returns the correct metadata by minting an NFT first so tokenURI(0) has valid data to return. It is good practice to always encode a defining metadata. without it, the token is just an entry with no descriptive value.

Test 4: Ensuring Invalid Token ID Fails as Expected

it("Should revert for invalid tokenId", async function () {
  // Attempt to get tokenURI for a non-existent tokenId
  await expect(eden.tokenURI(1)).to.be.revertedWith("Token ID not found");
});

since this contract exists with only one NFT with token ID 0, this check ensures that querying tokenURI() with an invalid ID (e.g 1,2,3 .. .) fails and throws an error “Token ID not found”.

Full test code below:

const { expect } = require("chai");
const { ethers } = require("hardhat");

describe("Eden Contract", function () {
  let Eden;
  let eden;
  let owner;
  let addr1;

  beforeEach(async function () {
    // Get the ContractFactory and Signers
    Eden = await ethers.getContractFactory("Eden");
    [owner, addr1] = await ethers.getSigners();

    // Deploy the contract
    eden = await Eden.deploy(); // No need for .deployed()
  });

  describe("Deployment", function () {
    it("Should set the correct name and symbol", async function () {
      expect(await eden.name()).to.equal("Eden");
      expect(await eden.symbol()).to.equal("EDEN");
    });

    it("Should mint a token with tokenId 0", async function () {
      // Mint a token
      await eden.mint();

      // Check the owner of tokenId 0
      expect(await eden.ownerOf(0)).to.equal(owner.address);
    });
  });

  describe("Token URI", function () {
    it("Should return the correct tokenURI for tokenId 0", async function () {
      // Mint a token
      await eden.mint();

      // Get the tokenURI
      const tokenURI = await eden.tokenURI(0);

      // Decode the Base64-encoded JSON metadata
      const base64Json = tokenURI.split(",")[1];
      const jsonString = Buffer.from(base64Json, "base64").toString("utf-8");
      const metadata = JSON.parse(jsonString);

      // Verify the metadata
      expect(metadata.name).to.equal("Garden of Eden");
      expect(metadata.description).to.equal("A depiction of the Garden of Eden as an NFT.");
      expect(metadata.image).to.include("data:image/svg+xml;base64");
    });

    it("Should revert for invalid tokenId", async function () {
      // Attempt to get tokenURI for a non-existent tokenId
      await expect(eden.tokenURI(1)).to.be.revertedWith("Token ID not found");
    });
  });
});

If the above code is written correctly, at the command of npx hardhat test, a successful test message is thrown (just as seen below).

Deploying the Eden Contract (locally and onchain)

By deploying the contract locally, we ascertain that our deployment script works es expected, just like we did for the test script above. When we deploy onchain (testnet or mainnet), we make it available on the blockchain with all its functionalities. We’ll deploy locally first, and then on a testnet.

1. Deployment setup

const { ethers } = require("hardhat");
async function main() {
  • ethers provides functions to interact with Ethereum, including deploying contracts. This is needed to compile and deploy the contract, as Hardhat’s built-in ethers version integrates seamlessly with local and test networks.

  • the main() async function contains the deployment logic, making it easier to call and handle errors.

2. Retrieving and Deploying the Eden Contract

  const Eden = await ethers.getContractFactory("Eden");
  console.log("Deploying Eden contract...");
  const eden = await Eden.deploy();
  • To deploy a contract, we first need its blueprint, which is obtained from the compiled contract factory. ethers.getContractFactory("Eden") fetches the contract’s ABI and bytecode (which are generated after contract is successfully compiled).

  • afterwards, Eden.deploy() creates a new instance of the contract and sends it to the network.await ensures we wait for deployment to complete.

3. Contract Logging & Error Handling

  console.log("Eden deployed to:", await eden.getAddress());
main()
  .then(() => process.exit(0))
  .catch((error) => {
    console.error(error);
    process.exit(1);
  });
  • eden.getAddress() retrieves the newly deployed contract’s blockchain address while console.log(...) prints it to the console. This lets us know where the contract exists on the blockchain, so we can interact with it later.

  • if there’s an error in deployment, we want to know so as to properly debug. if the process is successful, it exits with process.exit(0) but if not, it to the console and exits with process.exit(1).

Full Deployment Script

const { ethers } = require("hardhat");

async function main() {
  const Eden = await ethers.getContractFactory("Eden");

  console.log("Deploying Eden contract...");
  const eden = await Eden.deploy();

  // Wait for deployment (Ethers v6)
  await eden.waitForDeployment();

  console.log("Eden deployed to:", await eden.getAddress());
}

main()
  .then(() => process.exit(0))
  .catch((error) => {
    console.error(error);
    process.exit(1);
  });

To run this, you have to ensure that node is running by initializing npx hardhat node. A list of test addresses are generated. When you’ve confirmed this, open a new integrated terminal and run the deployment command npx hardhat run script/deploy.js --network localhost. ceteris paribus, you should see a successful message in your console like below:

Testnet Deployment

Deploying on a testnet takes things even further.

as displayed above, you’ll have to modify your hardhat.config.js to include a test network for deployment. in this case, PolygonAmoy. also, ensure that your .env is properly configured to avoid running into errors. here is a list of things you’ll have to configure in your .env file:

  • AMOY_RPC_URL

  • POLYGONSCAN_API_KEY

  • PRIVATE_KEY (of your address, funded with some test token)

Note: ensure that your .env is properly set up to avoid the list above getting stolen when you push to github (or any other tool you may be using). to set up .env, run npm install dotenv --save and then manually create a file with the name .env; that’s where your sensitive details are pasted and they are ignored when pushing to github because .env are found in the .gitignore file (which tells git to ignore these files when pushing to github).

After this is properly set up, run: npx hardhat run scripts/deploy.js --network <test network> in this case, ‘amoy’. if this is successful, you should get a success message just like when you deployed locally on your machine.

I deployed mine last night but forgot to take a screenshot but here is my deployment address: 0xe7c97880e7fb1e9cc52ce266449d1c5efddd0a3c. you can verify it on amoy.polygonscan

full hardhat.config.js code:

require("@nomicfoundation/hardhat-toolbox");
require("@nomicfoundation/hardhat-verify");
require("@nomicfoundation/hardhat-ignition");
// require("hardhat-ignition");
require("dotenv").config(); // Load env variables

module.exports = {
  solidity: "0.8.28",
  networks: {
    amoy: {
      url: process.env.AMOY_RPC_URL, // Use RPC URL from .env
      accounts: [process.env.PRIVATE_KEY], // Load private key
    },
  },
  etherscan: {
    apiKey: {
      polygonAmoy: process.env.POLYGONSCAN_API_KEY, // Use Etherscan API key from .env
    },
  },
};

Minting the NFT and successful display on Opensea testnet

Progress so far:

  • Eden contract, test, and deployment script ✅

  • local deployment and onchain deployment (on polygon amon) ✅

  • minting the NFT and its successful display on Opensea testnet … let’s get into it

Ideally, there should be a frontend that users/minters/ or anyone calling for a mint should interface with, but in this piece, we will be calling the mint function via the amoy.polygon chain explorer.

Below is the Eden contract onchain (click here to check through). We are interested in the mint function but we can also see a ‘connect to Web3’ button.

When I click on the ‘connect to web3’ button, i’m prompted to connect with any wallet on my web browser.

When the connection is made, the connected address is displayed. this address can now write on the blockchain i.e call the mint function. writing costs gas and this is why you’re required to have some test token in the address. it will be higher on the mainnet but because it is a testnet, it will be very low.

When a user clicks on the write (mint) function, it initializes the wallet again (in this case, metamask), indicating how much this mint costs and the time it takes. there’s a confirm button on the metamask side (that i accidentally cut out) but clicking it approves the transaction.

Signing into testnets.opensea with the same address shows me my minted Eden NFT as displayed below! 🎉

Progress so far:

  • Eden contract, test, and deployment script ✅

  • local deployment and onchain deployment (on polygon amon) ✅

  • minting the NFT and its successful display on Opensea testnet ✅

That’s about it for now. If you found this useful, don’t forget to like and share 🚀

Want to drop a comment/feedback for improvement? feel free to do so!

References

Edit: after you deploy onchain, you may realize that you can’t find the details of your contract on your testnet chain explorer. This is normal. You have to verify your contract before you can see the details and call the mint function. For more on verifying your contract, click here

0
Subscribe to my newsletter

Read articles from mosamorphing directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

mosamorphing
mosamorphing