This uses the base project created in the Hardhat Tutorial. You should be in the /sample-coin> directory on your terminal instance.
If OpenZeppelin is not installed yet, install it by running:
yarn add @openzeppelin/contractsWe inherit an ERC721 contract from OpenZeppelin ERC721 token contract.
Some explanations about our ERC721 NFT contract:
- TokenID starts at 1 and auto-increments by 1.
- Everyone can mint a NFT token by calling
mintTo(to)with Token ID.
Create a new contract file under the contracts directory called SampleToken.sol:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import "@openzeppelin/contracts/token/ERC721/ERC721.sol";
contract SampleToken is ERC721 {
uint256 private _currentTokenId = 0;//Token ID here will start from 1
constructor(
string memory _name,
string memory _symbol
) ERC721(_name, _symbol) {
}
/**
* @dev Mints a token to an address with a tokenURI.
* @param _to address of the future owner of the token
*/
function mintTo(address _to) public {
uint256 newTokenId = _getNextTokenId();
_mint(_to, newTokenId);
_incrementTokenId();
}
/**
* @dev calculates the next token ID based on value of _currentTokenId
* @return uint256 for the next token ID
*/
function _getNextTokenId() private view returns (uint256) {
return _currentTokenId+1;
}
/**
* @dev increments the value of _currentTokenId
*/
function _incrementTokenId() private {
_currentTokenId++;
}
}The contract we will create will rely on solidity version 0.8.20. At the time of writting the hardhat.config.ts defaults to 0.8.19. To support our contract we need to change the configuration as follows:
const config: HardhatUserConfig = {
solidity: "0.8.20",
};
Compile the contract:
yarn hardhat compileCreate a deploy script in the scripts directory called deploy_SampleToken.ts:
import { ethers } from "hardhat";
async function main() {
const SampleToken = await ethers.getContractFactory("SampleToken");
console.log('Deploying SampleToken ERC721 token...');
const token = await SampleToken.deploy('SampleToken','Sample');
await token.waitForDeployment();
console.log("SampleToken deployed to:", await token.getAddress());
}
main().catch((error) => {
console.error(error);
process.exitCode = 1;
});Deploy the smart contract using the deploy_SampleToken.ts script:
yarn hardhat run scripts/deploy_SampleToken.tsOutput:
SampleToken deployed to: 0x5FbDB2315678afecb367f032d93F642f64180aa3
Again, we write a simple unit test for our SampleToken ERC721 token:
- Check name and symbol
- Mint 2 NFTs
Create a new file under the test directory called SampleToken.test.ts:
// We import Chai to use its asserting functions here.
import { loadFixture } from "@nomicfoundation/hardhat-network-helpers";
import { expect } from "chai";
import { ethers } from "hardhat";
describe("SampleToken", function () {
async function deploySampleTokenFixture() {
// Contracts are deployed using the first signer/account by default
const [owner, otherAccount] = await ethers.getSigners();
const SampleToken = await ethers.getContractFactory("SampleToken");
const token = await SampleToken.deploy('SampleToken','Sample');
return { token, owner, otherAccount };
}
describe("Deployment", function () {
it("Should has the correct name and symbol", async function () {
const { token, owner } = await loadFixture(deploySampleTokenFixture);
const total = await token.balanceOf(owner.address);
expect(total).to.equal(0);
expect(await token.name()).to.equal('SampleToken');
expect(await token.symbol()).to.equal('Sample');
});
});
describe("Mint NFT", function () {
it("Should mint a token with token ID 1 & 2 to account1", async function () {
const { token, owner, otherAccount } = await loadFixture(deploySampleTokenFixture);
const address1=otherAccount.address;
await token.mintTo(address1);
expect(await token.ownerOf(1)).to.equal(address1);
await token.mintTo(address1);
expect(await token.ownerOf(2)).to.equal(address1);
expect(await token.balanceOf(address1)).to.equal(2);
});
});
});Run unit tests:
yarn hardhat test test/SampleToken.test.tsOutput:
SampleToken Deployment ✔ Should has the correct name and symbol (978ms) Mint NFT ✔ Should mint a token with token ID 1 & 2 to account1 2 passing (1s)
Note: Step 5/6 is an advanced topic. You can also refer to my other tutorial: Web3 Tutorial: Build an NFT marketplace DApp like OpenSea
We need to do base64 encoding in Solidity. The SVG format image is encodes with base64 and then included in the metadata. Metadata is also encoded with base64.
We use the base64.sol library to conduct base64 encode adapted from the project. 0xMoJo7 wrote a on-chain SVG generation tutorial on dev.to, you can also refer to this link: https://dev.to/0xmojo7/on-chain-svg-generation-part-1-2678.
Create a new contract file called base64.sol (latest version here):
// SPDX-License-Identifier: MIT
pragma solidity >=0.6.0;
/// @title Base64
/// @author Brecht Devos - <brecht@loopring.org>
/// @notice Provides functions for encoding/decoding base64
library Base64 {
string internal constant TABLE_ENCODE = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/';
bytes internal constant TABLE_DECODE = hex"0000000000000000000000000000000000000000000000000000000000000000"
hex"00000000000000000000003e0000003f3435363738393a3b3c3d000000000000"
hex"00000102030405060708090a0b0c0d0e0f101112131415161718190000000000"
hex"001a1b1c1d1e1f202122232425262728292a2b2c2d2e2f303132330000000000";
function encode(bytes memory data) internal pure returns (string memory) {
if (data.length == 0) return '';
// load the table into memory
string memory table = TABLE_ENCODE;
// multiply by 4/3 rounded up
uint256 encodedLen = 4 * ((data.length + 2) / 3);
// add some extra buffer at the end required for the writing
string memory result = new string(encodedLen + 32);
assembly {
// set the actual output length
mstore(result, encodedLen)
// prepare the lookup table
let tablePtr := add(table, 1)
// input ptr
let dataPtr := data
let endPtr := add(dataPtr, mload(data))
// result ptr, jump over length
let resultPtr := add(result, 32)
// run over the input, 3 bytes at a time
for {} lt(dataPtr, endPtr) {}
{
// read 3 bytes
dataPtr := add(dataPtr, 3)
let input := mload(dataPtr)
// write 4 characters
mstore8(resultPtr, mload(add(tablePtr, and(shr(18, input), 0x3F))))
resultPtr := add(resultPtr, 1)
mstore8(resultPtr, mload(add(tablePtr, and(shr(12, input), 0x3F))))
resultPtr := add(resultPtr, 1)
mstore8(resultPtr, mload(add(tablePtr, and(shr( 6, input), 0x3F))))
resultPtr := add(resultPtr, 1)
mstore8(resultPtr, mload(add(tablePtr, and( input, 0x3F))))
resultPtr := add(resultPtr, 1)
}
// padding with '='
switch mod(mload(data), 3)
case 1 { mstore(sub(resultPtr, 2), shl(240, 0x3d3d)) }
case 2 { mstore(sub(resultPtr, 1), shl(248, 0x3d)) }
}
return result;
}
function decode(string memory _data) internal pure returns (bytes memory) {
bytes memory data = bytes(_data);
if (data.length == 0) return new bytes(0);
require(data.length % 4 == 0, "invalid base64 decoder input");
// load the table into memory
bytes memory table = TABLE_DECODE;
// every 4 characters represent 3 bytes
uint256 decodedLen = (data.length / 4) * 3;
// add some extra buffer at the end required for the writing
bytes memory result = new bytes(decodedLen + 32);
assembly {
// padding with '='
let lastBytes := mload(add(data, mload(data)))
if eq(and(lastBytes, 0xFF), 0x3d) {
decodedLen := sub(decodedLen, 1)
if eq(and(lastBytes, 0xFFFF), 0x3d3d) {
decodedLen := sub(decodedLen, 1)
}
}
// set the actual output length
mstore(result, decodedLen)
// prepare the lookup table
let tablePtr := add(table, 1)
// input ptr
let dataPtr := data
let endPtr := add(dataPtr, mload(data))
// result ptr, jump over length
let resultPtr := add(result, 32)
// run over the input, 4 characters at a time
for {} lt(dataPtr, endPtr) {}
{
// read 4 characters
dataPtr := add(dataPtr, 4)
let input := mload(dataPtr)
// write 3 bytes
let output := add(
add(
shl(18, and(mload(add(tablePtr, and(shr(24, input), 0xFF))), 0xFF)),
shl(12, and(mload(add(tablePtr, and(shr(16, input), 0xFF))), 0xFF))),
add(
shl( 6, and(mload(add(tablePtr, and(shr( 8, input), 0xFF))), 0xFF)),
and(mload(add(tablePtr, and( input , 0xFF))), 0xFF)
)
)
mstore(resultPtr, shl(232, output))
resultPtr := add(resultPtr, 3)
}
}
return result;
}
}Metadata is returned by ERC721 API function tokenURI. Update the SampleToken.sol contract with the following:
- Import
base64.soland OpenZeppelin Utils Strings.sol - Implement function tokenURI(tokenId). We create a SVG format image with black background and white tokenId. Name is set as Sample+tokenId ("Sample #1" for example). Description is set as "An ERC721 Hardhat tutorial Sample NFT with on-chain SVG images like look."
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import "@openzeppelin/contracts/token/ERC721/ERC721.sol";
import "@openzeppelin/contracts/utils/Strings.sol";
import "./base64.sol";
contract SampleToken is ERC721 {
uint256 private _currentTokenId = 0;//Token ID here will start from 1
constructor(
string memory _name,
string memory _symbol
) ERC721(_name, _symbol) {
}
/**
* @dev Mints a token to an address with a tokenURI.
* @param _to address of the future owner of the token
*/
function mintTo(address _to) public {
uint256 newTokenId = _getNextTokenId();
_mint(_to, newTokenId);
_incrementTokenId();
}
/**
* @dev calculates the next token ID based on value of _currentTokenId
* @return uint256 for the next token ID
*/
function _getNextTokenId() private view returns (uint256) {
return _currentTokenId+1;
}
/**
* @dev increments the value of _currentTokenId
*/
function _incrementTokenId() private {
_currentTokenId++;
}
/**
* @dev return tokenURI, image SVG data in it.
*/
function tokenURI(uint256 tokenId) override public pure returns (string memory) {
string[3] memory parts;
parts[0] = '<svg xmlns="http://www.w3.org/2000/svg" preserveAspectRatio="xMinYMin meet" viewBox="0 0 350 350"><style>.base { fill: white; font-family: serif; font-size: 14px; }</style><rect width="100%" height="100%" fill="black" /><text x="10" y="20" class="base">';
parts[1] = Strings.toString(tokenId);
parts[2] = '</text></svg>';
string memory output = string(abi.encodePacked(parts[0], parts[1], parts[2]));
string memory json = Base64.encode(bytes(string(abi.encodePacked('{"name": "Sample #', Strings.toString(tokenId), '", "description": "An ERC721 Hardhat tutorial Sample NFT with on-chain SVG images like look.", "image": "data:image/svg+xml;base64,', Base64.encode(bytes(output)), '"}'))));
output = string(abi.encodePacked('data:application/json;base64,', json));
return output;
}
}Compile, test and deploy:
yarn hardhat compile
yarn hardhat test test/SampleToken.test.ts
yarn hardhat run scripts/deploy_SampleToken.tsIn another terminal, run command line in tutorial directory:
yarn hardhat nodeOn your working terminal, deploy the contract:
yarn hardhat run scripts/deploy_SampleToken.ts --network localhostOutput:
Deploying SampleToken ERC721 token... SampleToken deployed to: 0x5FbDB2315678afecb367f032d93F642f64180aa3
Open hardhat console:
yarn hardhat console --network localhostRun the following:
const address = '0x5FbDB2315678afecb367f032d93F642f64180aa3';
const token721 = await ethers.getContractAt("SampleToken", address);
const accounts = await hre.ethers.getSigners();
owner = accounts[0].address;
toAddress = accounts[1].address;
await token721.symbol()
//'Sample'Mint NFT and view it in online tools:
//mint NFT tokenId 1
await token721.mintTo(toAddress)
//mint NFT tokenId 2
await token721.mintTo(toAddress)
//mint NFT tokenId 3
await token721.mintTo(toAddress)
await token721.balanceOf(toAddress)
//3nGet the metadata by calling tokenURI:
await token721.tokenURI(3)Output:
'data:application/json;base64,eyJuYW1lIjogIlNhbXBsZSAjMyIsICJkZXNjcmlwdGlvbiI6ICJBbiBFUkM3MjEgSGFyZGhhdCB0dXRvcmlhbCBTYW1wbGUgTkZUIHdpdGggb24tY2hhaW4gU1ZHIGltYWdlcyBsaWtlIGxvb2suIiwgImltYWdlIjogImRhdGE6aW1hZ2Uvc3ZnK3htbDtiYXNlNjQsUEhOMlp5QjRiV3h1Y3owaWFIUjBjRG92TDNkM2R5NTNNeTV2Y21jdk1qQXdNQzl6ZG1jaUlIQnlaWE5sY25abFFYTndaV04wVW1GMGFXODlJbmhOYVc1WlRXbHVJRzFsWlhRaUlIWnBaWGRDYjNnOUlqQWdNQ0F6TlRBZ016VXdJajQ4YzNSNWJHVStMbUpoYzJVZ2V5Qm1hV3hzT2lCM2FHbDBaVHNnWm05dWRDMW1ZVzFwYkhrNklITmxjbWxtT3lCbWIyNTBMWE5wZW1VNklERTBjSGc3SUgwOEwzTjBlV3hsUGp4eVpXTjBJSGRwWkhSb1BTSXhNREFsSWlCb1pXbG5hSFE5SWpFd01DVWlJR1pwYkd3OUltSnNZV05ySWlBdlBqeDBaWGgwSUhnOUlqRXdJaUI1UFNJeU1DSWdZMnhoYzNNOUltSmhjMlVpUGpNOEwzUmxlSFErUEM5emRtYysifQ='
We will use the online base64 decoder https://www.base64decode.org/ to get the original data. Use the hash from the previous ouput to decode:
eyJuYW1lIjogIlNhbXBsZSAjMyIsICJkZXNjcmlwdGlvbiI6ICJBbiBFUkM3MjEgSGFyZGhhdCB0dXRvcmlhbCBTYW1wbGUgTkZUIHdpdGggb24tY2hhaW4gU1ZHIGltYWdlcyBsaWtlIGxvb2suIiwgImltYWdlIjogImRhdGE6aW1hZ2Uvc3ZnK3htbDtiYXNlNjQsUEhOMlp5QjRiV3h1Y3owaWFIUjBjRG92TDNkM2R5NTNNeTV2Y21jdk1qQXdNQzl6ZG1jaUlIQnlaWE5sY25abFFYTndaV04wVW1GMGFXODlJbmhOYVc1WlRXbHVJRzFsWlhRaUlIWnBaWGRDYjNnOUlqQWdNQ0F6TlRBZ016VXdJajQ4YzNSNWJHVStMbUpoYzJVZ2V5Qm1hV3hzT2lCM2FHbDBaVHNnWm05dWRDMW1ZVzFwYkhrNklITmxjbWxtT3lCbWIyNTBMWE5wZW1VNklERTBjSGc3SUgwOEwzTjBlV3hsUGp4eVpXTjBJSGRwWkhSb1BTSXhNREFsSWlCb1pXbG5hSFE5SWpFd01DVWlJR1pwYkd3OUltSnNZV05ySWlBdlBqeDBaWGgwSUhnOUlqRXdJaUI1UFNJeU1DSWdZMnhoYzNNOUltSmhjMlVpUGpNOEwzUmxlSFErUEM5emRtYysifQ==
We need to conduct two decode processes: first, decode the output data; second, decode the SVG image data.
In the first decode process, we get the following result. You can read the name and description in it:
Output:
{"name": "Sample #3", "description": "An ERC721 Hardhat tutorial Sample NFT with on-chain SVG images like look.", "image": "data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHByZXNlcnZlQXNwZWN0UmF0aW89InhNaW5ZTWluIG1lZXQiIHZpZXdCb3g9IjAgMCAzNTAgMzUwIj48c3R5bGU+LmJhc2UgeyBmaWxsOiB3aGl0ZTsgZm9udC1mYW1pbHk6IHNlcmlmOyBmb250LXNpemU6IDE0cHg7IH08L3N0eWxlPjxyZWN0IHdpZHRoPSIxMDAlIiBoZWlnaHQ9IjEwMCUiIGZpbGw9ImJsYWNrIiAvPjx0ZXh0IHg9IjEwIiB5PSIyMCIgY2xhc3M9ImJhc2UiPjM8L3RleHQ+PC9zdmc+"}
The SVG data is still in base64. We use the hash in our previous output. Let's decode it:
Input:
PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHByZXNlcnZlQXNwZWN0UmF0aW89InhNaW5ZTWluIG1lZXQiIHZpZXdCb3g9IjAgMCAzNTAgMzUwIj48c3R5bGU+LmJhc2UgeyBmaWxsOiB3aGl0ZTsgZm9udC1mYW1pbHk6IHNlcmlmOyBmb250LXNpemU6IDE0cHg7IH08L3N0eWxlPjxyZWN0IHdpZHRoPSIxMDAlIiBoZWlnaHQ9IjEwMCUiIGZpbGw9ImJsYWNrIiAvPjx0ZXh0IHg9IjEwIiB5PSIyMCIgY2xhc3M9ImJhc2UiPjM8L3RleHQ+PC9zdmc+Output:
<svg xmlns="http://www.w3.org/2000/svg" preserveAspectRatio="xMinYMin meet" viewBox="0 0 350 350"><style>.base { fill: white; font-family: serif; font-size: 14px; }</style><rect width="100%" height="100%" fill="black" /><text x="10" y="20" class="base">3</text></svg>You can take a look at the image in online SVG viewers such as https://www.svgviewer.dev/: <style>.base { fill: white; font-family: serif; font-size: 14px; }</style>3
Next: Nodejs Express Dapp using ethers.js
Adapted from fangjun's Series: A Concise Hardhat Tutorial Series' Articles