Table of contents
- Prerequisites
- Some Fundamentals
- Building an ERC721 contract that stores the NFT onchain in SVG.
- Testing and deploying the contract above on testnet
- Testing the Eden Contract
- Test Setup: Imports
- Defining the test
- Deploying a fresh contract before each test
- Test 1: Verifying Contract Deployment
- Test 2: NFT Minting Test
- Test 3: Validating Token Metadata
- Test 4: Ensuring Invalid Token ID Fails as Expected
- Deploying the Eden Contract (locally and onchain)
- 1. Deployment setup
- 2. Retrieving and Deploying the Eden Contract
- 3. Contract Logging & Error Handling
- Testnet Deployment
- Minting the NFT and successful display on Opensea testnet
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:
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.
Collectibles: Projects like CryptoKitties and NBA Top Shot use ERC721 tokens to represent unique collectibles.
Gaming: In-game items, characters, and assets can be tokenized as NFTs, allowing players to own and trade them.
Virtual Real Estate: Platforms like Decentraland use ERC721 tokens to represent parcels of virtual land.
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 versions0.8.28
and above, but not0.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 anERC721
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 nameEden
and symbolEDEN
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.emsg.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 aspure
to optimize gas costs. Also, it overrides thetokenURI
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 ID0
. 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.
- The metadata is encoded in Base64 and prefixed with
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
, andshould
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")
andrequire("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-inethers
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 whileconsole.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 withprocess.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