Hello you fellow coders
!
With the recent craze on NFT space, i ponder myself on how would one build an NFT smart contract. As with any emerging technology buzzword blockchain, businesses building NFT projects now will have the advantage of early adopters.
NFTs allow businesses to connect with their audience in new ways by providing exclusive value and experiences they wouldn't have been able to provide before.
Here i will guide through on how i build a somewhat a mid/advanced NFT smart contract.
In this project, here are the list of features which is common and the new OpenSea's On-Chain Enforcement Tool (operator-filter)
- Batch Minting (ERC721a)
Allowing user to mint multiple tokens within a single call.
- Whitelist / Allowlist
Using MerkleProof to validate user address if they are white-listed.
- Pre-Reveal / Reveal
Pre-reveal placeholders are used for new generative projects. The artwork wonโt actually be revealed until the NFT is minted, and collectors will only know which NFT in the collection they own once they buy the NFT.
- OpenSea's On-Chain Royalties Enforcement Tool (operator-filter)
A smart contract that enforce creator royalties.
Initialise project
In this tutorial, i will be using hardhat for smart contract development. Hardhat is a development environment for Ethereum software in a nutshell. You can even write test cases against your contract to ensure there no security issue, since once contract is deployed is immutable on the blockchain!
Lets start with creating a directory and installing hardhat as a dev dependency and initialise hardhat.
$ mkdir nft-smart-contract && cd nft-smart-contract && yarn add -D hardhat && npx hardhat
Follow the steps given by hardhat, in my project i've selected typescript. This will then generates the necessary folder for you to start building your smart contract.
Next installing dependency
$ yarn add -D erc721a @openzeppelin/contracts
As you can see we are installing erc721a an implementation of ERC721 for batch-minting and reduces gas fees during NFT Batch minting, credits to the @cygaar Azuki Dev. And also @openzeppelin which provides an open-source framework to build secure smart contracts.
Create our first NFT contract!
In the contracts folder create a new NftSample.sol
file or name it to your choosing.
In the example here, we're importing and inheriting ERC721A and Ownable.
ERC721A allow us to inherit their batch-minting function later when we modify our mint()
function
Ownable which provides a basic access control mechanism, where there is an account (an owner) that can be granted exclusive access to specific functions. This module is used through inheritance.
Minting (Public)
Next we will need to create our mint()
function to allow user to mint multiple tokens. We also will set our token supply to 10k.
Here are some of the requirements we will be adding.
- check if minting it available base on phases.
- max limit per user address.
- check if enough tokens left
- check if the value is correct
function mint(uint256 quantity) external payable {
// check if public sale is live
if (mintPhase != 2) revert PublicSaleNotLive();
// check if enough token balance
if (totalSupply() + quantity > maxSupply) {
revert NotEnoughTokensLeft();
}
// check for the value user pass is equal to the quantity and the mintRate
if (mintRate * quantity != msg.value) {
revert WrongEther();
}
// check for user mint limit
if (quantity + usedAddresses[msg.sender] > maxMints) {
revert ExceededLimit();
}
usedAddresses[msg.sender] += quantity;
_mint(msg.sender, quantity);
}
In the example above, we add a check if user are able to mint by setting a phase. We then check if theres enough token available to mint. To ensure user does not pass the wrong value, we've added a check and throw an error if so. Then we do a check if user has already minted the limit allowed by add mapping the user address and the quantity they have minted.
To allow user to batch-mint we inherit _mint()
that derived from ERC721a and than we set the quantity user minted in a mapping function.
White list mint
For our whitelist mint, there are couple of requirements to ensure of whitelist addresses are allowed to call this function. We will be using MerkleProof to verify user address.
/**
* @dev a function that only allow whitelisted addresses to mint
*/
function whiteListMint(uint256 quantity, bytes32[] calldata proof)
external
payable
{
// check if white list sale is live
if (mintPhase != 1) revert WhitelistNotLive();
// check if the user is white listed.
if (!isWhiteListed(msg.sender, proof)) revert InvalidMerkle();
// check if enough token balance
if (totalSupply() + quantity > maxSupply) {
revert NotEnoughTokensLeft();
}
// check for the value user pass is equal to the quantity and the mintRate
if (whiteListRate * quantity != msg.value) {
revert WrongEther();
}
//check if user exceeded mint limit
if (whiteListUsedAddresses[msg.sender] + quantity > whiteListMaxMints) {
revert ExceededLimit();
}
_mint(msg.sender, quantity);
// storing the number of minted items
whiteListUsedAddresses[msg.sender] += quantity;
}
/**
* @dev a function that check for user address and verify its proof
*/
function isWhiteListed(address _account, bytes32[] calldata _proof)
internal
view
returns (bool)
{
return _verify(leaf(_account), _proof);
}
function leaf(address _account) internal pure returns (bytes32) {
return keccak256(abi.encodePacked(_account));
}
function _verify(bytes32 _leaf, bytes32[] memory _proof)
internal
view
returns (bool)
{
return MerkleProof.verify(_proof, merkleRoot, _leaf);
}
As you can see in the example whiteListMint
write function, there's 2 arguments, quantity
& proof
.
-
quantity
- the number of tokens user would like to mint -
proof
- an array of hex strings which is required to verify withmerkleRootHash
that we will be adding later on.
We're adding isWhiteListed
get function to check and return a boolean
which uses MerkleProof.verify(_proof, merkleRoot, _leaf)
which we inherit from @openzeppelin/contracts/utils/cryptography/MerkleProof.sol
for verification.
In the nutshell, Merkle proof confirms specific transactions/values represented by a leaf or branch hash within a Merkle hash root, verify it and return a boolean.
Here the list of check for our, whiteListMint
- check if whitelistmint is live.
- check if user is whitelisted.
- check the there token left to be minted.
- check if the value send if correct for minting
- check if user exceeded white list mint limit.
Merkle Root Hash
In order to make whitelisting of addresses works, we need to generate merkle root hash which is a hex string and store in our contract. We will be adding a utils/scripts to do just that, in the example i'am using typescript, but you can use which ever programming language your prefer.
We will be installing the dependencies.
$ yarn add -D keccak265 merkletreejs
// utils/merkle.ts
import { ethers } from "ethers";
import keccak256 from "keccak256";
import { MerkleTree } from "merkletreejs";
/**
* This is to ensure the addresses is valid and injects the checksum
*/
export const getAndConvertAddresses = (addresses: string[]): string[] => {
if (!addresses?.length) return [];
const convertedAddress = addresses.map((addr) => {
return ethers.utils.getAddress(addr);
});
return convertedAddress;
};
const generateMerkle = (addresses: string[]) => {
if (!addresses?.length) return null;
// This is to ensure the addresses is valid and injects the checksum
const convertedAddresses = getAndConvertAddresses(addresses);
/**
* Create a new array of `leafNodes` by hashing all indexes of the `whitelistAddresses`
* using `keccak256`. Then creates a Merkle Tree object using keccak256 as the algorithm.
* The leaves, merkleTree, and rootHas are all PRE-DETERMINED prior to whitelist claim
*/
const leafNodes = convertedAddresses.map((addr) => keccak256(addr));
const merkleTree = new MerkleTree(leafNodes, keccak256, { sortPairs: true });
const merkleRootHash = merkleTree.getRoot().toString("hex");
const merkleTreeString = merkleTree.toString();
return {
merkleTree,
merkleRootHash: `0x${merkleRootHash}`,
merkleTreeString,
};
};
/**
* A function that generates merkle proof hex string
* @param whitelistAddresses | string []
* @param walletAddress | string
* @returns string[]
*/
const getMerkleProof = (
whitelistAddresses: string[],
walletAddress: string
) => {
const convertedAddresses = getAndConvertAddresses([walletAddress]);
const merkle = generateMerkle(whitelistAddresses);
const hashAddress = keccak256(convertedAddresses[0]);
const proof = merkle?.merkleTree.getHexProof(hashAddress);
return proof || [];
};
const isWhiteList = (
whitelistAddresses: string[],
walletAddress: string
): boolean => {
const convertedAddresses = getAndConvertAddresses([walletAddress]);
const merkle = generateMerkle(whitelistAddresses);
const hashAddress = keccak256(convertedAddresses[0]);
const proof = merkle?.merkleTree?.getHexProof(hashAddress) || [];
const verify = merkle?.merkleTree.verify(
proof,
hashAddress,
merkle?.merkleRootHash
);
return !!verify;
};
export { generateMerkle, getMerkleProof, isWhiteList, getAndConvertAddresses };
In the example above, we created 4 functions.
getAndConvertAddresses
- check if the address is a correct checksum addresses before we hash the addresses.
generateMerkle
- a function that generate merkle root hash that we will be using and add in the contract.
getMerkleProof
- a function that return us proof that will be use for white list mint.
isWhiteList
- a function to check if the address is white listed. We can use this function to check in the client-side for e.g
Generating Merkle Root Hash
Next, we will be using generateMerkle()
to generate our Merkle root hash and we can either set in our variable in the contract or set it later let's say their changes in the addresses.
In the example above, we're simple creating a script and console log the output for our merkle root hash. Than we run the script.
$ npx ts-node scripts/generateMerkle.ts
The output will look something like this.
For this example, i'am going to just add in our contract.
Phew that was a lot of codes, now let's take a coffee break, no serious you deserve it! โ๏ธ
Mint Phases
There couple of ways to handle mint phases for minting. Like checking the block time stamp if matches a specific timestamp. But for this example for setting public or whitelist phases. We simply add a check for both mint()
& whiteListMint()
if the phases return a boolean.
uint256 public mintPhase;
function mint(uint256 quantity) external payable {
// check if public sale is live
if (mintPhase != 2) revert PublicSaleNotLive();
...
}
/**
* @dev a function that only allow whitelisted addresses to mint
*/
function whiteListMint(uint256 quantity, bytes32[] calldata proof)
external
payable
{
// check if white list sale is live
if (mintPhase != 1) revert WhitelistNotLive();
...
}
/**
* @dev a function to set mint phase
*/
function setMintPhase(uint256 _phase) external onlyOwner {
mintPhase = _phase;
}
In the example above, we create a setMintPhase() which accepts uint256 and only the owner can call this write function. This function allow only the owner to set the mint phases as such.
- 1 = whitelist phase.
- 2 = public phase.
TokenURI
As any NFT ERC721 contract, we need to include tokenUri get function to fetch the metadata of of that NFT by token id.
function tokenURI(uint256 _tokenId)
public
view
override
returns (string memory)
{
require(_exists(_tokenId), "token does not exist!");
if (revealed)
return
string(
abi.encodePacked(
baseURI,
_tokenId.toString(),
baseExtension
)
);
else {
return
string(
abi.encodePacked(
baseHiddenUri,
_tokenId.toString(),
baseExtension
)
);
}
}
tokenUri
function accept an uint256 _tokenId, it check if the token exist. In our custom tokenUri function, we added a check for returning a revealed & unrevealed base on the the revealed boolean we added in our contract.
DefaultOperator by Opensea
How it works?
Token smart contracts may register themselves (or be registered by their "owner") with the OperatorFilterRegistry. Token contracts or their "owner"s may then curate lists of operators (specific account addresses) and codehashes (smart contracts deployed with the same code) that should not be allowed to transfer tokens on behalf of users.
// ==========================================
// ======== OPERATOR FILTER OVERRIDES =======
// ==========================================
function transferFrom(
address from,
address to,
uint256 tokenId
) public payable override onlyAllowedOperator(from) {
super.transferFrom(from, to, tokenId);
}
function safeTransferFrom(
address from,
address to,
uint256 tokenId
) public payable override onlyAllowedOperator(from) {
super.safeTransferFrom(from, to, tokenId);
}
function safeTransferFrom(
address from,
address to,
uint256 tokenId,
bytes memory data
) public payable override onlyAllowedOperator(from) {
super.safeTransferFrom(from, to, tokenId, data);
}
Let's wrap up ๐ฏ
For the rest of the implementation, you can have a look at my open source project here: nft-smart-contract
It's been a fun project to build while learning through all the required tools/libraries to make this works.
If you like my blog please support me by giving my project โญ๏ธ. You can reach me out at Linktree. And again thank you for reading ๐ and stay safe you unicorn ๐ฆ!
Top comments (0)