Skip to content

Latest commit

 

History

History
550 lines (413 loc) · 17.9 KB

File metadata and controls

550 lines (413 loc) · 17.9 KB

ERC721 Token Tutorial

This uses the base project created in the Hardhat Tutorial. You should be in the /sample-coin> directory on your terminal instance.

Step 1: Install OpenZeppelin


If OpenZeppelin is not installed yet, install it by running:

yarn add @openzeppelin/contracts


Step 2: Write an ERC721 Token with OpenZeppelin


We 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 compile


Step 3: Write a deploy script


Create 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.ts

Output:

SampleToken deployed to: 0x5FbDB2315678afecb367f032d93F642f64180aa3


Step 4: Write Unit Test


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.ts

Output:

  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)


Step 5: Add metadata: name, description and svg image

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.sol and 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.ts


Step 6: Interact with the NFT contract


In another terminal, run command line in tutorial directory:

yarn hardhat node

On your working terminal, deploy the contract:

yarn hardhat run scripts/deploy_SampleToken.ts --network localhost

Output:

Deploying SampleToken ERC721 token...
SampleToken deployed to: 0x5FbDB2315678afecb367f032d93F642f64180aa3

Open hardhat console:

yarn hardhat console --network localhost

Run 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)
//3n

Get 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

Home


Adapted from fangjun's Series: A Concise Hardhat Tutorial Series' Articles