This tutorial will guide you through building a simple NFT contract from scratch. You will clone the repository and progressively copy and paste code from this guide while understanding each step.
First, we'll upload the images and metadata for our NFTs to IPFS using a service called Pinata.
-
Sign Up on Pinata:
- Go to Pinata and sign up for an account.
-
Upload Images:
- Download the HackerBoostPunks folder to your computer.
- On the Pinata Dashboard, click on "Upload" and then select "Folder."
- Upload the
HackerBoostPunks
folder and name itHackerBoostPunks
. - After uploading, youβll receive a CID (Content Identifier) for your folder.
-
Verify Upload:
- To verify that the images are successfully uploaded, visit:
https://ipfs.io/ipfs/your-nft-folder-cid
(replaceyour-nft-folder-cid
with your folderβs CID).
- To verify that the images are successfully uploaded, visit:
-
Upload Metadata:
- Each NFT should have associated metadata stored in a JSON file. For example:
{ "name": "1", "description": "NFT Collection for HackerBoost Members", "image": "ipfs://CID-OF-THE-HackerBoostPunks-Folder/1.png" }
- Replace
"CID-OF-THE-HackerBoostPunks-Folder"
with the CID you received.
- Each NFT should have associated metadata stored in a JSON file. For example:
Now each NFT's metadata has been uploaded to IPFS and pinata should have generated a CID for your metadata folder
- We have pre-generated files for metadata for you, you can download them to your computer by clicking HERE and upload this metadata folder to pinata.
- Store Metadata CID:
- Once uploaded, copy the CID of the metadata folder and save it. Youβll need this later in the tutorial.
NFTs need metadata (name, image, description, etc.). We store the base URI for metadata.
// 1οΈβ£ Storage for NFT metadata
string private _baseTokenURI;
πΉ Explanation:
_baseTokenURI
will store the base URL where the NFT metadata (JSON files) are stored (e.g., IPFS or Arweave).
We need to track total supply, maximum NFTs, and price per NFT.
// 2οΈβ£ NFT data tracking
uint256 public tokenId;
uint256 public maxTokensIds = 10;
uint256 public price = 0.01 ether;
bool public paused;
πΉ Explanation:
totalSupply
β Keeps track of how many NFTs have been minted.maxTokensIds
β Limits the number of NFTs that can be minted.price
β Defines the minting cost per NFT.paused
β Used to pause the contract if needed.
We need mappings to assign token IDs to owners.
// 3οΈβ£ Mapping to track owners and token existence
mapping(uint256 => address) public tokenOwners;
mapping(address => uint256[]) public ownedTokens;
πΉ Explanation:
tokenOwners
β Maps token ID to owner address.ownedTokens
β Maps owner address to an array of token IDs they own.
The contract should have an owner with special privileges.
// 4οΈβ£ Owner of the contract
address public owner;
πΉ Explanation:
owner
β Stores the Ethereum address of the contract owner.
We initialize the contract during deployment.
// 5οΈβ£ Constructor to set contract ownership and base URI
constructor(string memory baseURI) {
owner = msg.sender;
_baseTokenURI = baseURI;
}
πΉ Explanation:
- The
constructor
sets the owner and the base URI for metadata.
Modifiers restrict access to only the owner or when the contract is not paused.
// 6οΈβ£ Modifiers for access control
modifier onlyOwner() {
require(msg.sender == owner, "Not contract owner");
_;
}
modifier whenNotPaused() {
require(!paused, "Contract is currently paused");
_;
}
πΉ Explanation:
onlyOwner
β Restricts certain functions only to the contract owner.whenNotPaused
β Prevents minting while the contract is paused.
Users should be able to mint an NFT by paying Ether.
// 7οΈβ£ Function to handle token minting
function mint() public payable whenNotPaused {
require(totalSupply < maxTokensIds, "Max supply reached");
require(msg.value >= price, "Insufficient Ether");
tokenId++;
tokenOwners[tokenId] = msg.sender;
ownedTokens[msg.sender].push(tokenId);
emit Minted(msg.sender, tokenId);
}
πΉ Explanation:
- Ensures max supply is not exceeded.
- Ensures users pay the correct amount.
- Assigns new token ID to the minter.
- Updates ownership mapping.
- Emits a Minted event.
Emit an event every time an NFT is minted.
// 8οΈβ£ Event for Minting NFTs
event Minted(address indexed owner, uint256 indexed tokenId);
πΉ Explanation:
- Events allow frontends to listen for minting actions.
Owners should be able to transfer their NFTs.
// 9οΈβ£ Function to transfer NFT ownership
function transfer(uint256 _tokenId, address receiver) public {
require(tokenOwners[_tokenId] == msg.sender, "Not the token owner");
require(receiver != address(0), "Invalid recipient address");
tokenOwners[_tokenId] = receiver;
uint256[] storage senderTokens = ownedTokens[msg.sender];
for (uint256 i = 0; i < senderTokens.length; i++) {
if (senderTokens[i] == _tokenId) {
senderTokens[i] = senderTokens[senderTokens.length - 1];
senderTokens.pop();
break;
}
}
ownedTokens[receiver].push(_tokenId);
emit Transferred(msg.sender, receiver, _tokenId);
}
πΉ Explanation:
- Checks that only the owner can transfer.
- Updates ownership mapping.
- Emits a Transferred event.
Emits an event when an NFT is transferred.
// 10οΈβ£ Event for NFT Transfers
event Transferred(address indexed from, address indexed receiver, uint256 indexed tokenId);
πΉ Explanation:
- Useful for tracking NFT transfers on the blockchain.
Users should be able to check how many NFTs they own.
// 11οΈβ£ Function to check balance (number of NFTs owned)
function balanceOf(address _owner) public view returns (uint256) {
return ownedTokens[_owner].length;
}
πΉ Explanation:
- Returns the total number of NFTs owned by a specific address.
Retrieves a token ID at a specific index.
// 12οΈβ£ Function to get token ID by index
function tokenOfOwnerByIndex(address _owner, uint256 index) public view returns (uint256) {
require(index < ownedTokens[_owner].length, "Invalid index");
return ownedTokens[_owner][index];
}
πΉ Explanation:
- Allows enumeration of a user's NFTs.
Retrieves metadata URI for an NFT.
// 13οΈβ£ Function to retrieve token metadata
function tokenURI(uint256 _tokenId) public view returns (string memory) {
require(tokenOwners[_tokenId] != address(0), "Token does not exist");
return string(abi.encodePacked(_baseTokenURI, Strings.toString(_tokenId), ".json"));
}
πΉ Explanation:
- Returns metadata URI (e.g., IPFS link).
The contract owner should be able to withdraw funds.
// 14οΈβ£ Function to withdraw contract balance
function withdraw() public onlyOwner {
(bool success, ) = payable(owner).call{value: address(this).balance}("");
require(success, "Withdrawal failed");
}
πΉ Explanation:
- Transfers all Ether in the contract to the owner.
Allows the contract to receive Ether.
// 15οΈβ£ Allow the contract to receive Ether
receive() external payable {}
fallback() external payable {}
πΉ Explanation:
- Ensures the contract does not reject incoming payments.
π Compile the contract to check for errors:
npx hardhat compile
π Open ignition/modules/deployHackerBoostPunks.js
.
- Set the correct baseURL
π Open a new terminal and deploy the contract to arbitrum sepolia:
npx hardhat ignition deploy "./ignition/modules/deployHackerStakingContract.ts" --network arbitrumSepolia
This will display the contract address in the terminal.
- Copy the contract address shown in the terminal.
- Paste it inside /frontend/contractAddress.json This allows the frontend to interact with the deployed contract.
- Move into the frontend folder and install dependencies
- The start the app in the development mode
cd ../frontend
npm install
npm run dev
π Open the browser and go to http://localhost:3000. π Mint NFTs by clicking the "Mint NFT" button. π Transfer NFTs between different accounts using MetaMask.