Create, deploy and mint smart contract (ERC-721) with NodeJS + Hardhat + Walletconnect + Web3modal
Igor
Posted on September 30, 2022
We will create from scratch ERC-721 smartcontract, deploy it to Ethereum Goerli network using Hardhat + Solidity. For minting we will use NextJs + WalletConnect + Web3Modal
Before we start, I would like to mention, that you can use any network you want. With the help of this article, you can deploy to Mainnet also.
Demo: https://gapon2401.github.io/erc721-mint/
Smartcontract repo (erc721): https://github.com/gapon2401/erc721
Mint page repo (erc721-mint): https://github.com/gapon2401/erc721-mint
Smartcontract: Etherscan Goerli link
We need to go through the following steps to reach our goal:
- Creating NFT (ERC-721) smartcontract
- Configure hardhat.config.ts andΒ .env
- Create deploy function
- Create tests
- Gas price for deployment and minting the smartcontract
- Creating images and metadata for NFT
- Deploy and verify smartcontract
- Create demo website for minting
- (optional) Deep dive and customize
Step 1. Creating NFT (ERC-721) smartcontract
Create new project and install https://hardhat.org - Ethereum development environment. Let's call this project erc721
.
In command line run
npx hardhat
Select Create a Typescript project
.
In order to continue you will be asked to install some dependencies. The text may vary on the hardhat version.
I will run the following, because I will use yarn
:
yarn add -D hardhat@^2.10.2 @nomicfoundation/hardhat-toolbox@^1.0.1
We will use https://www.openzeppelin.com/ template for ERC-721 smartcontract, where all necessary functions have been implemented. You can take a look on it: https://github.com/OpenZeppelin/openzeppelin-contracts/blob/master/contracts/token/ERC721/ERC721.sol
Install openzeppelin packages and dotenv:
yarn add -D @openzeppelin/contracts dotenv
Remove everything from the folder contracts
and create a file ERC721Contract.sol
with the contents:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.14;
import "@openzeppelin/contracts/token/ERC721/ERC721.sol";
import "@openzeppelin/contracts/token/ERC721/extensions/ERC721URIStorage.sol";
import "@openzeppelin/contracts/utils/Counters.sol";
import "@openzeppelin/contracts/access/Ownable.sol";
contract ERC721Contract is ERC721, ERC721URIStorage, Ownable {
using Counters for Counters.Counter;
Counters.Counter private _tokenIds;
// Mint price - 0.1 ETH
uint256 public constant PRICE = 100000000000000000;
string private baseURI;
constructor(string memory _baseTokenURI, string memory _name, string memory _symbol)
ERC721(_name, _symbol) {
baseURI = _baseTokenURI;
}
/**
* @notice Contract might receive/hold ETH as part of the maintenance process.
*/
receive() external payable {}
/**
* @notice Mint only for owner
*/
function mintByOwner(address to, string calldata tokenUri) external onlyOwner
{
uint256 newItemId = _tokenIds.current();
_tokenIds.increment();
_safeMint(to, newItemId);
_setTokenURI(newItemId, tokenUri);
}
/**
* @notice Public mint
*/
function mint() external payable
{
require(PRICE <= msg.value, "INVALID_PRICE");
uint256 newItemId = _tokenIds.current();
_tokenIds.increment();
_safeMint(msg.sender, newItemId);
// Return money, if sender have sent more than 0.1 ETH
if (msg.value > PRICE) {
Address.sendValue(payable(msg.sender), msg.value - PRICE);
}
}
/**
* @notice Read the base token URI
*/
function _baseURI() internal view override returns (string memory) {
return baseURI;
}
/**
* @notice Update the base token URI
*/
function setBaseURI(string calldata uri) external onlyOwner {
baseURI = uri;
}
/**
* @notice Allow withdrawing funds
*/
function withdraw() external onlyOwner {
uint256 balance = address(this).balance;
Address.sendValue(payable(msg.sender), balance);
}
// The following functions are overrides required by Solidity.
function _burn(uint256 tokenId) internal override(ERC721, ERC721URIStorage) {
super._burn(tokenId);
}
/**
* @notice Add json extension to all token URI's
*/
function tokenURI(uint256 tokenId) public view override(ERC721, ERC721URIStorage) returns (string memory)
{
return string(abi.encodePacked(super.tokenURI(tokenId), '.json'));
}
}
If you have any difficulties with the code above, feel free to read more about the Solidity. Here is the summary:
-
constructor
- on deployment we will set the base URI for all tokens, name and a symbol to the token collection. -
mintByOwner
- is our function, which will create new NFT's for us. Only owner of the contract can call it. -
mint
- public mint. -
setBaseURI
- owner of the contract can change the base URI for tokens -
withdraw
- owner can call this function to transfer money from the contract to it's own wallet address
All other necessary functions are inhereted in this contract from openzeppelin.
The price of the tokens will be 0.1 ETH:
uint256 public constant PRICE = 100000000000000000;
Step 2. Configure hardhat.config.ts and .env
Install the following dependencies in order to escape the errors in developing π²
yarn add -D ethers @nomiclabs/hardhat-etherscan @typechain/hardhat hardhat-gas-reporter solidity-coverage @types/chai @types/mocha typescript ts-node @nomicfoundation/hardhat-chai-matchers chai @nomicfoundation/hardhat-network-helpers typechain @nomiclabs/hardhat-waffle @typechain/ethers-v5 @nomiclabs/hardhat-ethers
Now configure your hardhat.config.ts
:
import * as dotenv from "dotenv";
import { HardhatUserConfig } from "hardhat/config";
import "@nomicfoundation/hardhat-toolbox";
import "./tasks/deploy";
dotenv.config();
const config: HardhatUserConfig = {
solidity: {
version: "0.8.14",
settings: {
metadata: {
bytecodeHash: "none",
},
optimizer: {
enabled: true,
runs: 200,
},
},
},
networks: {
goerli: {
url: process.env.GOERLI_URL,
accounts:
process.env.PRIVATE_KEY !== undefined ? [process.env.PRIVATE_KEY] : [],
},
mainnet: {
url: process.env.MAINNET_URL,
accounts:
process.env.PRIVATE_KEY !== undefined ? [process.env.PRIVATE_KEY] : [],
},
},
gasReporter: {
enabled: !!process.env.REPORT_GAS,
},
etherscan: {
apiKey: process.env.ETHERSCAN_API_KEY,
},
};
export default config;
What do we have here:
solidity
- we specified the settings and enabled optimizationnetworks
- networks, that we are going to usegasReporter
- will show us gas units, that we need to spend for deployment or calling the functionsetherscan
- plugin for integration with Etherscan's contract verification service
The next step is to create .env
file in project root. You have to create 5 variables:
PRIVATE_KEY
- the private key of the wallet, that wil be the owner of the contractETHERSCAN_API_KEY
- etherscan key. Register on https://etherscan.io and on the page https://etherscan.io/myapikey create and copy your key:
REPORT_GAS=1
- Are we going to use gas reporter or not. We will use itGOERLI_URL
andMAINNET_URL
- use https://infura.io/ or https://www.alchemy.com/ for getting the urls.
For instance, infura URL for MAINNET_URL
:
Infura URL for GOERLI_URL
:
Now compile the code with the command:
npx hardhat compile
After that command some new folders will appear: artifacts
, cache
, typechain-types
.
Step 3. Create deploy function
It's time to write deploy function, we will use tasks for it.
Remove folder scripts
, we don't need it.
Create the folder tasks
and the file deploy.ts
inside it:
import { task } from "hardhat/config";
task("deploy").setAction(async function (_, { ethers, run }) {
console.log("Start deploying");
try {
// The path to token metadata
const baseUri = "https://my-domain.com/collection/metadata/";
const _name = "My ERC721 name";
const _symbol = "MyERC721Symbol";
const ERC721Contract = await ethers.getContractFactory("ERC721Contract");
const erc721Contract = await ERC721Contract.deploy(baseUri, _name, _symbol);
await erc721Contract.deployed();
console.log("Contract deployed to address:", erc721Contract.address);
} catch (error) {
console.error(error);
process.exit(1);
}
});
In this task we get smartcontract and deploy it. After the deployment in console we will see the address of the contract.
We will come back to that function later.
Step 4. Create tests
We will write the tests for every exteral/public function.
Clear the folder test
and create index.ts
inside it with the contents:
import { ethers } from "hardhat";
import { SignerWithAddress } from "@nomiclabs/hardhat-ethers/dist/src/signer-with-address";
import { ERC721Contract } from "../typechain-types";
import { expect } from "chai";
interface Signers {
admin: SignerWithAddress;
user: SignerWithAddress;
}
describe("ERC721Contract", function () {
before(async function () {
this.signers = {} as Signers;
const signers: SignerWithAddress[] = await ethers.getSigners();
this.signers.admin = signers[0];
this.signers.user = signers[1];
this.baseUri = "https://my-domain.com/collection/metadata/";
});
beforeEach(async function () {
const _name = "My ERC721 name";
const _symbol = "MyERC721Symbol";
const ERC721ContractFactory = await ethers.getContractFactory(
"ERC721Contract"
);
this.contract = <ERC721Contract>(
await ERC721ContractFactory.deploy(this.baseUri, _name, _symbol)
);
await this.contract.deployed();
});
it("Mint 5 tokens by owner", async function () {
await this.contract.mintByOwner(
this.signers.user.address,
"link_to_token_json1"
);
await this.contract.mintByOwner(
this.signers.user.address,
"link_to_token_json2"
);
await this.contract.mintByOwner(
this.signers.user.address,
"link_to_token_json3"
);
await this.contract.mintByOwner(
this.signers.user.address,
"link_to_token_json4"
);
await this.contract.mintByOwner(
this.signers.user.address,
"link_to_token_json5"
);
// Check balance of the user
expect(await this.contract.balanceOf(this.signers.user.address)).to.equal(
5
);
});
it("Revert, if user is trying to use mintByOwner", async function () {
expect(
this.contract
.connect(this.signers.user)
.mintByOwner(this.signers.user.address, "link_to_token_json1")
).to.be.reverted;
});
it("Mint token by user", async function () {
await this.contract
.connect(this.signers.user)
.mint({ value: ethers.utils.parseEther("0.1") });
expect(await this.contract.balanceOf(this.signers.user.address)).to.equal(
1
);
});
it("Should revert INVALID_PRICE", async function () {
expect(
this.contract
.connect(this.signers.user)
.mint({ value: ethers.utils.parseEther("0.05") })
).to.be.revertedWith("INVALID_PRICE");
});
it("Should return back overpaid eth to sender", async function () {
const price = ethers.utils.parseEther("0.1");
expect(
this.contract
.connect(this.signers.user)
.mint({ value: ethers.utils.parseEther("1.5") })
).to.changeEtherBalances(
[this.signers.user, this.contract],
[price.mul(-1), price]
);
});
it("Check receiving eth to contract and withdraw", async function () {
const value = ethers.utils.parseEther("1.5");
await this.signers.user.sendTransaction({
to: this.contract.address,
value,
});
expect(this.contract.withdraw()).to.changeEtherBalances(
[this.signers.admin, this.contract],
[value, value.mul(-1)]
);
});
it("Set base URI", async function () {
const value = ethers.utils.parseEther("0.1");
// Mint 2 tokens
await this.contract.connect(this.signers.user).mint({ value });
await this.contract.connect(this.signers.user).mint({ value });
// Check token URI's
expect(await this.contract.tokenURI(0)).to.be.equal(
this.baseUri + "0.json"
);
expect(await this.contract.tokenURI(1)).to.be.equal(
this.baseUri + "1.json"
);
// Change base URI
const newBaseUri = "https://new_domain_or_ipfs/";
await this.contract.setBaseURI(newBaseUri);
// Check new token URI's
expect(await this.contract.tokenURI(0)).to.be.equal(newBaseUri + "0.json");
expect(await this.contract.tokenURI(1)).to.be.equal(newBaseUri + "1.json");
});
it("Check token URI", async function () {
const value = ethers.utils.parseEther("0.1");
// Mint token by user
await this.contract.connect(this.signers.user).mint({ value });
// Check token URI
expect(await this.contract.tokenURI(0)).to.be.equal(
this.baseUri + "0.json"
);
// Mint token by owner with specified URI
await this.contract.mintByOwner(this.signers.user.address, "link_to_uri");
// Check token URI
expect(await this.contract.tokenURI(1)).to.be.equal(
this.baseUri + "link_to_uri.json"
);
});
});
Run npx hardhat test
.
You should get similar to that:
All tests passed successfully!
Step 5. Gas price for deployment and minting the smartcontract
For determine the price of deployment and minting functions we have used module hardhat-gas-reporter
. It was enabled by default in hardhat.config.ts
, that's why after all tests were passed, we got the table:
In the table we can see the gas consumption for methods and deployment.
This is the units of gas we need to deploy the contract.
How much it will cost in ETH?
Use the formula:
(gas units) * (gas price per unit) = gas fee in gwei
In this formula we don't know the amount of gas price per unit
.
You can use https://ethgasstation.info/ or any other website to find this information. At the moment of writing this article, gas price is 17.
The value can change greatly depending on the time of day. It may be 2 or 10 or 50 π. Be careful.
So, the average cost will be:
Deployment = 1 697 092 * 17 = 28 850 564 gwei = 0,028850564 ETH
mint = 88 753 * 17 = 1 508 801 gwei = 0,001508801 ETH
mintByOwner = 91 060 * 17 = 1 548 020 gwei = 0,001548020 ETH
setBaseURI = 32 153 * 17 = 546 601 gwei = 0,000546601 ETH
withdraw = 30 421 * 17 = 517 157 gwei = 0,000517157 ETH
Now the ETH/USD price is $1 592, it means that deployment to mainnet will cost about $46 and calling a mint
function will cost about $2.4.
Step 6. Creating images and metadata for NFT
Images and metadata for NFTs can be stored on your server or in IPFS.
We will cover the first case. Assume, that you have a domain
https://my-domain.com
All NFT images will store here:
https://my-domain.com/collection/assets/
All metadata here:
https://my-domain.com/collection/metadata/
So, the base URI for our smartcontract will be
https://my-domain.com/collection/metadata/
In our smartcontract by default all token URI's will be counted from 0. Therefore, files for metadata should be like this:
0.json
1.json
....
1234.json
If you will use mintByOwner
function, then you can change the name of the file for specific NFT, and it can be like this: custom.json
Upload NFT images on your server. They should be available by links:
https://my-domain.com/collection/assets/0.png
https://my-domain.com/collection/assets/1.png
https://my-domain.com/collection/assets/2.png
Now create metadata for NFT's.
Here is an example of one file 0.json
:
{
"name": "Test NFT #0",
"image": "https://my-domain.com/collection/assets/0.png"
}
Pay attention, that in image
we have defined the link to the image, which has been uploaded to the server.
Read more about opensea metadata and available values in it.
Upload your metadata files, so they should be available by links:
https://my-domain.com/collection/metadata/0.json
https://my-domain.com/collection/metadata/1.json
https://my-domain.com/collection/metadata/2.json
Step 7. Deploy and verify smartcontract
Everything is ready for deployment.
Take a look on our tasks/deploy.ts
, we've specified baseUri
as
https://my-domain.com/collection/metadata/
This is because all our metadata is available by this address.
Here you can change the name
and the symbol
of the collection.
In command line run:
npx hardhat deploy --network goerli
This command will deploy smartcontract to Goerli network. If you want to deploy to mainnet, you can run:
npx hardhat deploy --network mainnet
All these networks were described on hardhat.config.ts
file.
After the deployment you will get the following in your console:
My smartcontract address is:
0xc191B6505B16EBe5D776fb30EFbfe41A9252023a
From now, it is available on etherscan testnet:
https://goerli.etherscan.io/address/0xc191B6505B16EBe5D776fb30EFbfe41A9252023a
We need to verify our smartcontract, so everyone can check the code.
Wait about 5 minutes and try to verify it. If you get an error while verifying, don't worry, just try to wait more time. In Mainnet I was waiting for 10β15 minutes.
In general verify function looks like this:
npx hardhat verify --network <YOUR_NETWORK> <DEPLOYED_CONTRACT_ADDRESS> <arg1> <arg2> <argn>
Let's try to determine, what to specify in our verify function:
<YOUR_NETWORK> -
goerli
<DEPLOYED_CONTRACT_ADDRESS> will be my contract address
0xc191B6505B16EBe5D776fb30EFbfe41A9252023a
- <arg1> - this is our base URI. You will find it in
tasks/deploy.ts
"https://my-domain.com/collection/metadata/"
- <arg2> - name of the token collection, that was specified in deployment:
"My ERC721 name"
- <arg3> - symbol of the token collection, that was specified in deployment:
"MyERC721Symbol"
Arguments should be the same, that were in tasks/deploy.ts
!!!
So, in command line I will run:
npx hardhat verify --network goerli 0xc191B6505B16EBe5D776fb30EFbfe41A9252023a "https://my-domain.com/collection/metadata/" "My ERC721 name" "MyERC721Symbol"
The result should be the next:
On this page you can check the code of deployed smartcontract.
π₯³We have finished with the smartcontract. Here is the repo, where you can find all the code.
Step 8. Create demo website for minting
This will be our second project erc721-mint
.
For demo website I will use NextJS.
To speed up the development clone my repository: https://github.com/gapon2401/erc721-mint
Install all dependencies:
yarn install
Open file .env
and fill in the variables:
NEXT_PUBLIC_SMARTCONTRACT_ADDRESS
- address of your smartcontract from the paragraph Deploy and verify smartcontract.NEXT_PUBLIC_SMARTCONTRACT_NETWORK
- the network, where your smartcontract was deployed. We need this to switch the network. Specifygoerli
, because we have deployed our smartcontact there.NEXT_PUBLIC_SMARTCONTRACT_INFURA_URL
- network endpoint. You can use https://infura.io to get the url.
Go to your dashboard, select your project and copy Api Key
:
In our first smartcontract project erc721
open the file:
artifacts/contracts/ERC721Contract.sol/ERC721Contract.json
Copy abi
:
In current project erc721-mint
open the file:
src/components/smartcontract.ts
Replace the abi
value.
Everything is done! π You can run the project yarn dev
and try to use it!
In the next section I will cover the main components.
Step 9 (optional). Deep dive and customize
Take a look on main components:
1) src/components/smartcontract.ts
Contains information about the smartcontract. It's better to dynamically load this file from the server, because we want our web page loads as soon as possible. In this demo mint form appears after the page loads.
2) src/components/Web3Provider.tsx
This component communicates with blockchain. There are some functions and variables stored in React Context:
connected
- has user connected the wallet or notisConnecting
- the state of reconnectingconnect()
- function for connecting to networkreconnect()
β function for reconnecting. Cached provider will be used, that was selected by user at the last timedisconnect()
β function for disconnectingnetwork
β selected by user network: goerli, mainnet etcprovider
β provider for communicating with the wallet: metamask, ledger, tokenary, etcsigner
- the owner of the walletchainId
β ID of the selected networkaccountId
β your wallet addresserror
- error message for web3Modal
3) src/components/MintForm.tsx
This component displays form for minting.
Function mint()
is called after the user clicks on the mint button.
Now look at the code:
const transactionResponse = await Contract.mint({
value: ethers.utils.parseEther('0.1'),
})
We are calling mint
function from the smartcontract and send 0.1 ETH to it, because this function requires exactly that value.
What if we want to call mintByOwner
?
We can do it like this:
const transactionResponse = await Contract.mintByOwner(
'0x01234566', 'token_uri'
)
But pay attention, that this function can be called only by the owner of the contract. Why? We have used onlyOwner
modifier in smartcontract:
function mintByOwner(address to, string calldata tokenUri) external onlyOwner
A few more words about that function.
0x01234566
- this is the address, for which you want to mint token. Make sure to write full address.
token_uri
- this is custom token URI. In our deployed smartcontract the full address to token metadata looks like:
${baseUri + token_uri}.json
Remember, we have specified baseUri
:
https://my-domain.com/collection/metadata/
So, the full address to metadata with token URI equals token_url
will be:
https://my-domain.com/collection/metadata/token_url.json
Also, we don't need to pay for this transaction, except gas price.
Function prepareNetwork()
.
On first connection this function can propose to change the network. In this function are used only goerli
and mainnet
networks. You can change it, if you need, here:
chainId: smartContract.network === 'goerli' ? '0x5' : '0x1',
Function getBalanceOf
.
This function checks is it enough money on the wallet to make a transaction.
In the file you may find commented code in two places:
/* reconnect ,*/
and
/* Reconnect to the wallet */
// useEffect(() => {
// ;(async () => {
// await reconnect()
// })()
// }, [reconnect])
Uncomment it to make reconnections every time the page reloads.
Thank you for reading! β€
Posted on September 30, 2022
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.