Web3 Blockchain Dapp Development

views

Building Decentralized Applications with Web3: A Complete Developer's Guide

From smart contracts to frontend integration - master the full stack of Web3 development

Introduction

The blockchain revolution has transformed how we think about trust, ownership, and digital transactions. Web3 represents the next evolution of the internet - a decentralized web where users control their data and interact directly with protocols rather than intermediaries.

In this comprehensive tutorial, we'll build a complete decentralized application (dApp) from scratch. You'll learn how to write secure smart contracts, deploy them to a blockchain network, and create a modern frontend that interacts with the blockchain seamlessly.

By the end of this guide, you'll have built a fully functional NFT marketplace - one of the most popular and technically interesting dApp types.

Prerequisites

Before starting, ensure you have:

  • Node.js v16+ installed
  • Basic JavaScript/TypeScript knowledge
  • Familiarity with React (we'll use Next.js)
  • A code editor (VS Code recommended)
  • MetaMask browser extension installed

Understanding the Web3 Stack

The Web3 development stack differs significantly from traditional web development:

€€€€€€€€€€€€€€€€€€€€€€€€€€€€€€€€€€€€€€€€€€€€
             Frontend (React/Next.js)        
溾€€€€€€€€€€€€€€€€€€€€€€€€€€€€€€€€€€€€€€€€€€€€
           Web3 Provider (ethers.js)         
溾€€€€€€€€€€€€€€€€€€€€€€€€€€€€€€€€€€€€€€€€€€€€
          Smart Contracts (Solidity)         
溾€€€€€€€€€€€€€€€€€€€€€€€€€€€€€€€€€€€€€€€€€€€€
           Blockchain Network                
       (Ethereum, Polygon, etc.)             
€€€€€€€€€€€€€€€€€€€€€€€€€€€€€€€€€€€€€€€€€€€€

Key Components

  1. Smart Contracts: Self-executing code stored on the blockchain
  2. Web3 Provider: JavaScript library to interact with the blockchain
  3. Wallet: User's identity and key management (MetaMask)
  4. IPFS: Decentralized storage for files and metadata

Setting Up the Development Environment

Let's start by setting up a professional Web3 development environment:

# Create a new project directory
mkdir nft-marketplace && cd nft-marketplace

# Initialize the project
npm init -y

# Install Hardhat - the Ethereum development environment
npm install --save-dev hardhat @nomicfoundation/hardhat-toolbox

# Install OpenZeppelin contracts for secure, audited implementations
npm install @openzeppelin/contracts

# Install frontend dependencies
npm install ethers@6 next react react-dom
npm install --save-dev typescript @types/react @types/node

Initialize Hardhat:

npx hardhat init

Choose "Create a TypeScript project" and accept the defaults.

Understanding Ethereum and Smart Contracts

Before writing code, let's understand the core concepts:

Gas and Transactions

Every operation on Ethereum costs "gas" - a unit measuring computational effort. Users pay gas fees to miners/validators who process transactions.

// Simple transaction flow:
// 1. User initiates transaction
// 2. Transaction enters mempool
// 3. Validators include it in a block
// 4. Block is confirmed
// 5. Transaction is finalized

The EVM (Ethereum Virtual Machine)

Smart contracts compile to EVM bytecode and run identically on every node in the network, ensuring consensus.

Storage vs Memory

Understanding data locations is crucial for gas optimization:

contract StorageExample {
    // Storage: Persistent, expensive
    uint256 public storedValue;
    
    function updateStorage(uint256 _value) external {
        storedValue = _value; // Writes to blockchain
    }
    
    function compute(uint256[] memory _values) external pure returns (uint256) {
        // Memory: Temporary, cheaper
        uint256 sum = 0;
        for (uint256 i = 0; i < _values.length; i++) {
            sum += _values[i];
        }
        return sum;
    }
}

Writing the NFT Smart Contract

Now let's create our NFT contract. Create a file contracts/NFTMarketplace.sol:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

import "@openzeppelin/contracts/token/ERC721/ERC721.sol";
import "@openzeppelin/contracts/token/ERC721/extensions/ERC721URIStorage.sol";
import "@openzeppelin/contracts/token/ERC721/extensions/ERC721Enumerable.sol";
import "@openzeppelin/contracts/access/Ownable.sol";
import "@openzeppelin/contracts/utils/ReentrancyGuard.sol";

/**
 * @title NFTMarketplace
 * @dev A decentralized NFT marketplace with minting and trading functionality
 * @author PlayHve Development Team
 */
contract NFTMarketplace is 
    ERC721, 
    ERC721URIStorage, 
    ERC721Enumerable, 
    Ownable, 
    ReentrancyGuard 
{
    // ============ State Variables ============
    
    uint256 private _tokenIdCounter;
    uint256 public listingFee = 0.025 ether;
    uint256 public mintingFee = 0.01 ether;
    
    // Mapping from token ID to listing info
    struct Listing {
        uint256 tokenId;
        address seller;
        uint256 price;
        bool active;
    }
    
    mapping(uint256 => Listing) public listings;
    
    // All active listing token IDs
    uint256[] private _activeListings;
    mapping(uint256 => uint256) private _listingIndex;
    
    // Royalty information
    struct RoyaltyInfo {
        address recipient;
        uint256 percentage; // Basis points (100 = 1%)
    }
    mapping(uint256 => RoyaltyInfo) public royalties;
    
    // ============ Events ============
    
    event NFTMinted(
        uint256 indexed tokenId,
        address indexed creator,
        string tokenURI
    );
    
    event NFTListed(
        uint256 indexed tokenId,
        address indexed seller,
        uint256 price
    );
    
    event NFTSold(
        uint256 indexed tokenId,
        address indexed seller,
        address indexed buyer,
        uint256 price
    );
    
    event ListingCancelled(
        uint256 indexed tokenId,
        address indexed seller
    );
    
    event PriceUpdated(
        uint256 indexed tokenId,
        uint256 oldPrice,
        uint256 newPrice
    );
    
    // ============ Constructor ============
    
    constructor() 
        ERC721("PlayHve NFT", "PHIVE") 
        Ownable(msg.sender) 
    {}
    
    // ============ Minting Functions ============
    
    /**
     * @dev Mint a new NFT with metadata URI
     * @param _tokenURI IPFS URI pointing to metadata JSON
     * @param _royaltyPercentage Creator royalty in basis points (max 10%)
     */
    function mintNFT(
        string memory _tokenURI,
        uint256 _royaltyPercentage
    ) external payable returns (uint256) {
        require(msg.value >= mintingFee, "Insufficient minting fee");
        require(_royaltyPercentage <= 1000, "Royalty cannot exceed 10%");
        
        _tokenIdCounter++;
        uint256 newTokenId = _tokenIdCounter;
        
        _safeMint(msg.sender, newTokenId);
        _setTokenURI(newTokenId, _tokenURI);
        
        // Set royalty info
        royalties[newTokenId] = RoyaltyInfo({
            recipient: msg.sender,
            percentage: _royaltyPercentage
        });
        
        emit NFTMinted(newTokenId, msg.sender, _tokenURI);
        
        return newTokenId;
    }
    
    /**
     * @dev Batch mint multiple NFTs
     * @param _tokenURIs Array of IPFS URIs
     * @param _royaltyPercentage Royalty percentage for all tokens
     */
    function batchMintNFTs(
        string[] memory _tokenURIs,
        uint256 _royaltyPercentage
    ) external payable returns (uint256[] memory) {
        require(_tokenURIs.length > 0, "Must mint at least one NFT");
        require(_tokenURIs.length <= 20, "Maximum 20 NFTs per batch");
        require(
            msg.value >= mintingFee * _tokenURIs.length,
            "Insufficient minting fee"
        );
        require(_royaltyPercentage <= 1000, "Royalty cannot exceed 10%");
        
        uint256[] memory tokenIds = new uint256[](_tokenURIs.length);
        
        for (uint256 i = 0; i < _tokenURIs.length; i++) {
            _tokenIdCounter++;
            uint256 newTokenId = _tokenIdCounter;
            
            _safeMint(msg.sender, newTokenId);
            _setTokenURI(newTokenId, _tokenURIs[i]);
            
            royalties[newTokenId] = RoyaltyInfo({
                recipient: msg.sender,
                percentage: _royaltyPercentage
            });
            
            tokenIds[i] = newTokenId;
            
            emit NFTMinted(newTokenId, msg.sender, _tokenURIs[i]);
        }
        
        return tokenIds;
    }
    
    // ============ Marketplace Functions ============
    
    /**
     * @dev List an NFT for sale
     * @param _tokenId Token ID to list
     * @param _price Sale price in wei
     */
    function listNFT(
        uint256 _tokenId, 
        uint256 _price
    ) external payable nonReentrant {
        require(ownerOf(_tokenId) == msg.sender, "Not the owner");
        require(_price > 0, "Price must be greater than 0");
        require(msg.value >= listingFee, "Insufficient listing fee");
        require(!listings[_tokenId].active, "Already listed");
        
        // Transfer NFT to marketplace (escrow)
        _transfer(msg.sender, address(this), _tokenId);
        
        // Create listing
        listings[_tokenId] = Listing({
            tokenId: _tokenId,
            seller: msg.sender,
            price: _price,
            active: true
        });
        
        // Add to active listings
        _listingIndex[_tokenId] = _activeListings.length;
        _activeListings.push(_tokenId);
        
        emit NFTListed(_tokenId, msg.sender, _price);
    }
    
    /**
     * @dev Purchase a listed NFT
     * @param _tokenId Token ID to purchase
     */
    function buyNFT(uint256 _tokenId) external payable nonReentrant {
        Listing memory listing = listings[_tokenId];
        
        require(listing.active, "Not listed for sale");
        require(msg.value >= listing.price, "Insufficient payment");
        require(msg.sender != listing.seller, "Cannot buy your own NFT");
        
        // Mark as inactive before transfers (reentrancy protection)
        listings[_tokenId].active = false;
        
        // Remove from active listings
        _removeFromActiveListings(_tokenId);
        
        // Calculate payments
        uint256 royaltyAmount = 0;
        RoyaltyInfo memory royaltyInfo = royalties[_tokenId];
        
        if (royaltyInfo.percentage > 0 && royaltyInfo.recipient != listing.seller) {
            royaltyAmount = (listing.price * royaltyInfo.percentage) / 10000;
        }
        
        uint256 sellerAmount = listing.price - royaltyAmount;
        
        // Transfer NFT to buyer
        _transfer(address(this), msg.sender, _tokenId);
        
        // Pay seller
        (bool sellerPaid, ) = payable(listing.seller).call{value: sellerAmount}("");
        require(sellerPaid, "Failed to pay seller");
        
        // Pay royalty if applicable
        if (royaltyAmount > 0) {
            (bool royaltyPaid, ) = payable(royaltyInfo.recipient).call{value: royaltyAmount}("");
            require(royaltyPaid, "Failed to pay royalty");
        }
        
        // Refund excess payment
        if (msg.value > listing.price) {
            (bool refunded, ) = payable(msg.sender).call{value: msg.value - listing.price}("");
            require(refunded, "Failed to refund excess");
        }
        
        emit NFTSold(_tokenId, listing.seller, msg.sender, listing.price);
    }
    
    /**
     * @dev Cancel a listing and return NFT to seller
     * @param _tokenId Token ID to cancel listing for
     */
    function cancelListing(uint256 _tokenId) external nonReentrant {
        Listing memory listing = listings[_tokenId];
        
        require(listing.active, "Not listed");
        require(listing.seller == msg.sender, "Not the seller");
        
        // Mark as inactive
        listings[_tokenId].active = false;
        
        // Remove from active listings
        _removeFromActiveListings(_tokenId);
        
        // Return NFT to seller
        _transfer(address(this), msg.sender, _tokenId);
        
        emit ListingCancelled(_tokenId, msg.sender);
    }
    
    /**
     * @dev Update the price of a listed NFT
     * @param _tokenId Token ID to update price for
     * @param _newPrice New price in wei
     */
    function updateListingPrice(
        uint256 _tokenId, 
        uint256 _newPrice
    ) external {
        Listing storage listing = listings[_tokenId];
        
        require(listing.active, "Not listed");
        require(listing.seller == msg.sender, "Not the seller");
        require(_newPrice > 0, "Price must be greater than 0");
        
        uint256 oldPrice = listing.price;
        listing.price = _newPrice;
        
        emit PriceUpdated(_tokenId, oldPrice, _newPrice);
    }
    
    // ============ View Functions ============
    
    /**
     * @dev Get all active listings
     */
    function getActiveListings() external view returns (Listing[] memory) {
        Listing[] memory activeItems = new Listing[](_activeListings.length);
        
        for (uint256 i = 0; i < _activeListings.length; i++) {
            activeItems[i] = listings[_activeListings[i]];
        }
        
        return activeItems;
    }
    
    /**
     * @dev Get listings by seller
     * @param _seller Seller address
     */
    function getListingsBySeller(
        address _seller
    ) external view returns (Listing[] memory) {
        uint256 count = 0;
        
        // Count seller's listings
        for (uint256 i = 0; i < _activeListings.length; i++) {
            if (listings[_activeListings[i]].seller == _seller) {
                count++;
            }
        }
        
        // Build result array
        Listing[] memory sellerListings = new Listing[](count);
        uint256 index = 0;
        
        for (uint256 i = 0; i < _activeListings.length; i++) {
            if (listings[_activeListings[i]].seller == _seller) {
                sellerListings[index] = listings[_activeListings[i]];
                index++;
            }
        }
        
        return sellerListings;
    }
    
    /**
     * @dev Get NFTs owned by address
     * @param _owner Owner address
     */
    function getNFTsByOwner(
        address _owner
    ) external view returns (uint256[] memory) {
        uint256 balance = balanceOf(_owner);
        uint256[] memory tokenIds = new uint256[](balance);
        
        for (uint256 i = 0; i < balance; i++) {
            tokenIds[i] = tokenOfOwnerByIndex(_owner, i);
        }
        
        return tokenIds;
    }
    
    /**
     * @dev Get total supply of NFTs
     */
    function getTotalSupply() external view returns (uint256) {
        return _tokenIdCounter;
    }
    
    // ============ Admin Functions ============
    
    /**
     * @dev Update listing fee (owner only)
     * @param _newFee New listing fee in wei
     */
    function setListingFee(uint256 _newFee) external onlyOwner {
        listingFee = _newFee;
    }
    
    /**
     * @dev Update minting fee (owner only)
     * @param _newFee New minting fee in wei
     */
    function setMintingFee(uint256 _newFee) external onlyOwner {
        mintingFee = _newFee;
    }
    
    /**
     * @dev Withdraw marketplace earnings (owner only)
     */
    function withdraw() external onlyOwner {
        uint256 balance = address(this).balance;
        require(balance > 0, "No balance to withdraw");
        
        (bool success, ) = payable(owner()).call{value: balance}("");
        require(success, "Withdrawal failed");
    }
    
    // ============ Internal Functions ============
    
    function _removeFromActiveListings(uint256 _tokenId) internal {
        uint256 index = _listingIndex[_tokenId];
        uint256 lastIndex = _activeListings.length - 1;
        
        if (index != lastIndex) {
            uint256 lastTokenId = _activeListings[lastIndex];
            _activeListings[index] = lastTokenId;
            _listingIndex[lastTokenId] = index;
        }
        
        _activeListings.pop();
        delete _listingIndex[_tokenId];
    }
    
    // ============ Overrides ============
    
    function _update(
        address to,
        uint256 tokenId,
        address auth
    ) internal override(ERC721, ERC721Enumerable) returns (address) {
        return super._update(to, tokenId, auth);
    }
    
    function _increaseBalance(
        address account,
        uint128 value
    ) internal override(ERC721, ERC721Enumerable) {
        super._increaseBalance(account, value);
    }
    
    function tokenURI(
        uint256 tokenId
    ) public view override(ERC721, ERC721URIStorage) returns (string memory) {
        return super.tokenURI(tokenId);
    }
    
    function supportsInterface(
        bytes4 interfaceId
    ) public view override(ERC721, ERC721URIStorage, ERC721Enumerable) returns (bool) {
        return super.supportsInterface(interfaceId);
    }
}

Writing Comprehensive Tests

Testing is crucial in smart contract development. Create test/NFTMarketplace.test.ts:

import { expect } from "chai";
import { ethers } from "hardhat";
import { NFTMarketplace } from "../typechain-types";
import { SignerWithAddress } from "@nomicfoundation/hardhat-ethers/signers";

describe("NFTMarketplace", function () {
  let marketplace: NFTMarketplace;
  let owner: SignerWithAddress;
  let seller: SignerWithAddress;
  let buyer: SignerWithAddress;
  let creator: SignerWithAddress;
  
  const MINTING_FEE = ethers.parseEther("0.01");
  const LISTING_FEE = ethers.parseEther("0.025");
  const SALE_PRICE = ethers.parseEther("1.0");
  const TOKEN_URI = "ipfs://QmTest123/metadata.json";
  const ROYALTY_PERCENTAGE = 500; // 5%
  
  beforeEach(async function () {
    [owner, seller, buyer, creator] = await ethers.getSigners();
    
    const NFTMarketplace = await ethers.getContractFactory("NFTMarketplace");
    marketplace = await NFTMarketplace.deploy();
    await marketplace.waitForDeployment();
  });
  
  describe("Deployment", function () {
    it("Should set the correct name and symbol", async function () {
      expect(await marketplace.name()).to.equal("PlayHve NFT");
      expect(await marketplace.symbol()).to.equal("PHIVE");
    });
    
    it("Should set the correct owner", async function () {
      expect(await marketplace.owner()).to.equal(owner.address);
    });
    
    it("Should set correct initial fees", async function () {
      expect(await marketplace.mintingFee()).to.equal(MINTING_FEE);
      expect(await marketplace.listingFee()).to.equal(LISTING_FEE);
    });
  });
  
  describe("Minting", function () {
    it("Should mint an NFT successfully", async function () {
      await expect(
        marketplace.connect(seller).mintNFT(TOKEN_URI, ROYALTY_PERCENTAGE, {
          value: MINTING_FEE,
        })
      )
        .to.emit(marketplace, "NFTMinted")
        .withArgs(1, seller.address, TOKEN_URI);
      
      expect(await marketplace.ownerOf(1)).to.equal(seller.address);
      expect(await marketplace.tokenURI(1)).to.equal(TOKEN_URI);
    });
    
    it("Should fail without sufficient minting fee", async function () {
      await expect(
        marketplace.connect(seller).mintNFT(TOKEN_URI, ROYALTY_PERCENTAGE, {
          value: ethers.parseEther("0.005"),
        })
      ).to.be.revertedWith("Insufficient minting fee");
    });
    
    it("Should reject royalty above 10%", async function () {
      await expect(
        marketplace.connect(seller).mintNFT(TOKEN_URI, 1001, {
          value: MINTING_FEE,
        })
      ).to.be.revertedWith("Royalty cannot exceed 10%");
    });
    
    it("Should batch mint multiple NFTs", async function () {
      const uris = [
        "ipfs://QmTest1/metadata.json",
        "ipfs://QmTest2/metadata.json",
        "ipfs://QmTest3/metadata.json",
      ];
      
      const tx = await marketplace.connect(seller).batchMintNFTs(
        uris, 
        ROYALTY_PERCENTAGE, 
        { value: MINTING_FEE * BigInt(uris.length) }
      );
      
      // Check all NFTs were minted
      expect(await marketplace.balanceOf(seller.address)).to.equal(3);
      expect(await marketplace.tokenURI(1)).to.equal(uris[0]);
      expect(await marketplace.tokenURI(2)).to.equal(uris[1]);
      expect(await marketplace.tokenURI(3)).to.equal(uris[2]);
    });
  });
  
  describe("Listing", function () {
    beforeEach(async function () {
      await marketplace.connect(seller).mintNFT(TOKEN_URI, ROYALTY_PERCENTAGE, {
        value: MINTING_FEE,
      });
    });
    
    it("Should list an NFT for sale", async function () {
      await expect(
        marketplace.connect(seller).listNFT(1, SALE_PRICE, {
          value: LISTING_FEE,
        })
      )
        .to.emit(marketplace, "NFTListed")
        .withArgs(1, seller.address, SALE_PRICE);
      
      const listing = await marketplace.listings(1);
      expect(listing.seller).to.equal(seller.address);
      expect(listing.price).to.equal(SALE_PRICE);
      expect(listing.active).to.be.true;
    });
    
    it("Should transfer NFT to marketplace on listing", async function () {
      await marketplace.connect(seller).listNFT(1, SALE_PRICE, {
        value: LISTING_FEE,
      });
      
      expect(await marketplace.ownerOf(1)).to.equal(
        await marketplace.getAddress()
      );
    });
    
    it("Should fail if not the owner", async function () {
      await expect(
        marketplace.connect(buyer).listNFT(1, SALE_PRICE, {
          value: LISTING_FEE,
        })
      ).to.be.revertedWith("Not the owner");
    });
    
    it("Should fail with zero price", async function () {
      await expect(
        marketplace.connect(seller).listNFT(1, 0, {
          value: LISTING_FEE,
        })
      ).to.be.revertedWith("Price must be greater than 0");
    });
  });
  
  describe("Buying", function () {
    beforeEach(async function () {
      await marketplace.connect(seller).mintNFT(TOKEN_URI, ROYALTY_PERCENTAGE, {
        value: MINTING_FEE,
      });
      
      await marketplace.connect(seller).listNFT(1, SALE_PRICE, {
        value: LISTING_FEE,
      });
    });
    
    it("Should allow buying a listed NFT", async function () {
      await expect(
        marketplace.connect(buyer).buyNFT(1, { value: SALE_PRICE })
      )
        .to.emit(marketplace, "NFTSold")
        .withArgs(1, seller.address, buyer.address, SALE_PRICE);
      
      expect(await marketplace.ownerOf(1)).to.equal(buyer.address);
    });
    
    it("Should pay the seller correctly", async function () {
      const sellerBalanceBefore = await ethers.provider.getBalance(seller.address);
      
      await marketplace.connect(buyer).buyNFT(1, { value: SALE_PRICE });
      
      const sellerBalanceAfter = await ethers.provider.getBalance(seller.address);
      
      // Seller receives full amount (no royalty since seller is also creator)
      expect(sellerBalanceAfter - sellerBalanceBefore).to.equal(SALE_PRICE);
    });
    
    it("Should handle royalty payments on secondary sales", async function () {
      // First sale: seller to buyer
      await marketplace.connect(buyer).buyNFT(1, { value: SALE_PRICE });
      
      // Buyer lists for resale
      await marketplace.connect(buyer).listNFT(1, SALE_PRICE, {
        value: LISTING_FEE,
      });
      
      const creatorBalanceBefore = await ethers.provider.getBalance(seller.address);
      
      // Another user buys
      await marketplace.connect(owner).buyNFT(1, { value: SALE_PRICE });
      
      const creatorBalanceAfter = await ethers.provider.getBalance(seller.address);
      const royaltyAmount = SALE_PRICE * BigInt(ROYALTY_PERCENTAGE) / BigInt(10000);
      
      expect(creatorBalanceAfter - creatorBalanceBefore).to.equal(royaltyAmount);
    });
    
    it("Should fail with insufficient payment", async function () {
      await expect(
        marketplace.connect(buyer).buyNFT(1, {
          value: ethers.parseEther("0.5"),
        })
      ).to.be.revertedWith("Insufficient payment");
    });
    
    it("Should refund excess payment", async function () {
      const excessAmount = ethers.parseEther("0.5");
      const buyerBalanceBefore = await ethers.provider.getBalance(buyer.address);
      
      const tx = await marketplace.connect(buyer).buyNFT(1, {
        value: SALE_PRICE + excessAmount,
      });
      
      const receipt = await tx.wait();
      const gasUsed = receipt!.gasUsed * receipt!.gasPrice;
      
      const buyerBalanceAfter = await ethers.provider.getBalance(buyer.address);
      
      // Buyer should have paid SALE_PRICE + gas, not SALE_PRICE + excessAmount + gas
      expect(buyerBalanceBefore - buyerBalanceAfter - gasUsed).to.equal(SALE_PRICE);
    });
  });
  
  describe("Cancel Listing", function () {
    beforeEach(async function () {
      await marketplace.connect(seller).mintNFT(TOKEN_URI, ROYALTY_PERCENTAGE, {
        value: MINTING_FEE,
      });
      
      await marketplace.connect(seller).listNFT(1, SALE_PRICE, {
        value: LISTING_FEE,
      });
    });
    
    it("Should allow seller to cancel listing", async function () {
      await expect(marketplace.connect(seller).cancelListing(1))
        .to.emit(marketplace, "ListingCancelled")
        .withArgs(1, seller.address);
      
      // NFT returned to seller
      expect(await marketplace.ownerOf(1)).to.equal(seller.address);
      
      // Listing is inactive
      const listing = await marketplace.listings(1);
      expect(listing.active).to.be.false;
    });
    
    it("Should fail if not the seller", async function () {
      await expect(
        marketplace.connect(buyer).cancelListing(1)
      ).to.be.revertedWith("Not the seller");
    });
  });
  
  describe("Update Price", function () {
    beforeEach(async function () {
      await marketplace.connect(seller).mintNFT(TOKEN_URI, ROYALTY_PERCENTAGE, {
        value: MINTING_FEE,
      });
      
      await marketplace.connect(seller).listNFT(1, SALE_PRICE, {
        value: LISTING_FEE,
      });
    });
    
    it("Should allow seller to update price", async function () {
      const newPrice = ethers.parseEther("2.0");
      
      await expect(marketplace.connect(seller).updateListingPrice(1, newPrice))
        .to.emit(marketplace, "PriceUpdated")
        .withArgs(1, SALE_PRICE, newPrice);
      
      const listing = await marketplace.listings(1);
      expect(listing.price).to.equal(newPrice);
    });
  });
  
  describe("View Functions", function () {
    beforeEach(async function () {
      // Mint and list multiple NFTs
      for (let i = 0; i < 3; i++) {
        await marketplace.connect(seller).mintNFT(
          `ipfs://QmTest${i}/metadata.json`,
          ROYALTY_PERCENTAGE,
          { value: MINTING_FEE }
        );
        
        await marketplace.connect(seller).listNFT(
          i + 1,
          SALE_PRICE,
          { value: LISTING_FEE }
        );
      }
    });
    
    it("Should return all active listings", async function () {
      const listings = await marketplace.getActiveListings();
      expect(listings.length).to.equal(3);
    });
    
    it("Should return listings by seller", async function () {
      const listings = await marketplace.getListingsBySeller(seller.address);
      expect(listings.length).to.equal(3);
    });
    
    it("Should return NFTs by owner", async function () {
      // Buy one NFT
      await marketplace.connect(buyer).buyNFT(1, { value: SALE_PRICE });
      
      const buyerNFTs = await marketplace.getNFTsByOwner(buyer.address);
      expect(buyerNFTs.length).to.equal(1);
      expect(buyerNFTs[0]).to.equal(1);
    });
  });
  
  describe("Admin Functions", function () {
    it("Should allow owner to update fees", async function () {
      const newMintingFee = ethers.parseEther("0.02");
      const newListingFee = ethers.parseEther("0.05");
      
      await marketplace.setMintingFee(newMintingFee);
      await marketplace.setListingFee(newListingFee);
      
      expect(await marketplace.mintingFee()).to.equal(newMintingFee);
      expect(await marketplace.listingFee()).to.equal(newListingFee);
    });
    
    it("Should allow owner to withdraw", async function () {
      // Create some fees in the contract
      await marketplace.connect(seller).mintNFT(TOKEN_URI, ROYALTY_PERCENTAGE, {
        value: MINTING_FEE,
      });
      
      const contractBalance = await ethers.provider.getBalance(
        await marketplace.getAddress()
      );
      
      const ownerBalanceBefore = await ethers.provider.getBalance(owner.address);
      const tx = await marketplace.withdraw();
      const receipt = await tx.wait();
      const gasUsed = receipt!.gasUsed * receipt!.gasPrice;
      
      const ownerBalanceAfter = await ethers.provider.getBalance(owner.address);
      
      expect(ownerBalanceAfter - ownerBalanceBefore + gasUsed).to.equal(
        contractBalance
      );
    });
    
    it("Should fail if non-owner tries admin functions", async function () {
      await expect(
        marketplace.connect(buyer).setMintingFee(ethers.parseEther("0.02"))
      ).to.be.revertedWithCustomError(marketplace, "OwnableUnauthorizedAccount");
    });
  });
});

Run the tests:

npx hardhat test

Deploying to a Testnet

Create a deployment script scripts/deploy.ts:

import { ethers } from "hardhat";

async function main() {
  console.log("Deploying NFTMarketplace...");
  
  const NFTMarketplace = await ethers.getContractFactory("NFTMarketplace");
  const marketplace = await NFTMarketplace.deploy();
  
  await marketplace.waitForDeployment();
  
  const address = await marketplace.getAddress();
  console.log(`NFTMarketplace deployed to: ${address}`);
  
  // Log configuration
  console.log("\nContract Configuration:");
  console.log(`- Minting Fee: ${ethers.formatEther(await marketplace.mintingFee())} ETH`);
  console.log(`- Listing Fee: ${ethers.formatEther(await marketplace.listingFee())} ETH`);
  console.log(`- Owner: ${await marketplace.owner()}`);
  
  return { marketplace, address };
}

main()
  .then(() => process.exit(0))
  .catch((error) => {
    console.error(error);
    process.exit(1);
  });

Configure Hardhat for testnet deployment in hardhat.config.ts:

import { HardhatUserConfig } from "hardhat/config";
import "@nomicfoundation/hardhat-toolbox";
import * as dotenv from "dotenv";

dotenv.config();

const config: HardhatUserConfig = {
  solidity: {
    version: "0.8.20",
    settings: {
      optimizer: {
        enabled: true,
        runs: 200,
      },
    },
  },
  networks: {
    hardhat: {},
    sepolia: {
      url: process.env.SEPOLIA_RPC_URL || "",
      accounts: process.env.PRIVATE_KEY  [process.env.PRIVATE_KEY] : [],
    },
    polygon_mumbai: {
      url: process.env.MUMBAI_RPC_URL || "",
      accounts: process.env.PRIVATE_KEY  [process.env.PRIVATE_KEY] : [],
    },
  },
  etherscan: {
    apiKey: process.env.ETHERSCAN_API_KEY,
  },
};

export default config;

Building the Frontend

Now let's create a Next.js frontend that interacts with our smart contract.

Create src/lib/web3.ts:

import { ethers, BrowserProvider, Contract, JsonRpcSigner } from "ethers";
import NFTMarketplaceABI from "../abi/NFTMarketplace.json";

const CONTRACT_ADDRESS = process.env.NEXT_PUBLIC_CONTRACT_ADDRESS || "";

export interface NFTMetadata {
  name: string;
  description: string;
  image: string;
  attributes: Array<{
    trait_type: string;
    value: string | number;
  }>;
}

export interface Listing {
  tokenId: bigint;
  seller: string;
  price: bigint;
  active: boolean;
}

export class Web3Service {
  private provider: BrowserProvider | null = null;
  private signer: JsonRpcSigner | null = null;
  private contract: Contract | null = null;
  
  async connect(): Promise<string> {
    if (typeof window.ethereum === "undefined") {
      throw new Error("MetaMask is not installed");
    }
    
    this.provider = new BrowserProvider(window.ethereum);
    
    // Request account access
    const accounts = await this.provider.send("eth_requestAccounts", []);
    
    this.signer = await this.provider.getSigner();
    this.contract = new Contract(
      CONTRACT_ADDRESS,
      NFTMarketplaceABI,
      this.signer
    );
    
    return accounts[0];
  }
  
  async getAccount(): Promise<string | null> {
    if (!this.provider) return null;
    
    try {
      const accounts = await this.provider.send("eth_accounts", []);
      return accounts[0] || null;
    } catch {
      return null;
    }
  }
  
  async getBalance(address: string): Promise<string> {
    if (!this.provider) throw new Error("Not connected");
    
    const balance = await this.provider.getBalance(address);
    return ethers.formatEther(balance);
  }
  
  async mintNFT(
    tokenURI: string,
    royaltyPercentage: number
  ): Promise<{ tokenId: bigint; txHash: string }> {
    if (!this.contract) throw new Error("Not connected");
    
    const mintingFee = await this.contract.mintingFee();
    
    const tx = await this.contract.mintNFT(tokenURI, royaltyPercentage, {
      value: mintingFee,
    });
    
    const receipt = await tx.wait();
    
    // Parse the NFTMinted event
    const mintEvent = receipt.logs.find((log: any) => {
      try {
        const parsed = this.contract!.interface.parseLog(log);
        return parsed.name === "NFTMinted";
      } catch {
        return false;
      }
    });
    
    const parsedEvent = this.contract.interface.parseLog(mintEvent);
    const tokenId = parsedEvent!.args[0];
    
    return { tokenId, txHash: receipt.hash };
  }
  
  async listNFT(tokenId: bigint, priceInEth: string): Promise<string> {
    if (!this.contract) throw new Error("Not connected");
    
    const listingFee = await this.contract.listingFee();
    const priceInWei = ethers.parseEther(priceInEth);
    
    const tx = await this.contract.listNFT(tokenId, priceInWei, {
      value: listingFee,
    });
    
    const receipt = await tx.wait();
    return receipt.hash;
  }
  
  async buyNFT(tokenId: bigint, priceInWei: bigint): Promise<string> {
    if (!this.contract) throw new Error("Not connected");
    
    const tx = await this.contract.buyNFT(tokenId, {
      value: priceInWei,
    });
    
    const receipt = await tx.wait();
    return receipt.hash;
  }
  
  async cancelListing(tokenId: bigint): Promise<string> {
    if (!this.contract) throw new Error("Not connected");
    
    const tx = await this.contract.cancelListing(tokenId);
    const receipt = await tx.wait();
    return receipt.hash;
  }
  
  async getActiveListings(): Promise<Listing[]> {
    if (!this.contract) throw new Error("Not connected");
    
    return await this.contract.getActiveListings();
  }
  
  async getMyNFTs(): Promise<bigint[]> {
    if (!this.contract || !this.signer) throw new Error("Not connected");
    
    const address = await this.signer.getAddress();
    return await this.contract.getNFTsByOwner(address);
  }
  
  async getTokenURI(tokenId: bigint): Promise<string> {
    if (!this.contract) throw new Error("Not connected");
    
    return await this.contract.tokenURI(tokenId);
  }
  
  async fetchMetadata(tokenURI: string): Promise<NFTMetadata> {
    // Convert IPFS URI to HTTP gateway URL
    const url = tokenURI.replace("ipfs://", "https://ipfs.io/ipfs/");
    
    const response = await fetch(url);
    if (!response.ok) {
      throw new Error("Failed to fetch metadata");
    }
    
    return await response.json();
  }
  
  // Event listeners
  onAccountsChanged(callback: (accounts: string[]) => void): void {
    if (typeof window.ethereum !== "undefined") {
      window.ethereum.on("accountsChanged", callback);
    }
  }
  
  onChainChanged(callback: (chainId: string) => void): void {
    if (typeof window.ethereum !== "undefined") {
      window.ethereum.on("chainChanged", callback);
    }
  }
  
  formatPrice(priceInWei: bigint): string {
    return ethers.formatEther(priceInWei);
  }
  
  parsePrice(priceInEth: string): bigint {
    return ethers.parseEther(priceInEth);
  }
}

export const web3Service = new Web3Service();

Create a React hook for wallet connection src/hooks/useWallet.ts:

import { useState, useEffect, useCallback } from "react";
import { web3Service } from "../lib/web3";

interface WalletState {
  address: string | null;
  balance: string;
  isConnected: boolean;
  isConnecting: boolean;
  error: string | null;
}

export function useWallet() {
  const [state, setState] = useState<WalletState>({
    address: null,
    balance: "0",
    isConnected: false,
    isConnecting: false,
    error: null,
  });
  
  const connect = useCallback(async () => {
    setState((prev) => ({ ...prev, isConnecting: true, error: null }));
    
    try {
      const address = await web3Service.connect();
      const balance = await web3Service.getBalance(address);
      
      setState({
        address,
        balance,
        isConnected: true,
        isConnecting: false,
        error: null,
      });
    } catch (error: any) {
      setState((prev) => ({
        ...prev,
        isConnecting: false,
        error: error.message || "Failed to connect wallet",
      }));
    }
  }, []);
  
  const updateBalance = useCallback(async () => {
    if (state.address) {
      const balance = await web3Service.getBalance(state.address);
      setState((prev) => ({ ...prev, balance }));
    }
  }, [state.address]);
  
  useEffect(() => {
    // Check if already connected
    const checkConnection = async () => {
      const account = await web3Service.getAccount();
      if (account) {
        await connect();
      }
    };
    
    checkConnection();
    
    // Set up event listeners
    web3Service.onAccountsChanged((accounts) => {
      if (accounts.length === 0) {
        setState({
          address: null,
          balance: "0",
          isConnected: false,
          isConnecting: false,
          error: null,
        });
      } else {
        connect();
      }
    });
    
    web3Service.onChainChanged(() => {
      // Reload the page on chain change for simplicity
      window.location.reload();
    });
  }, [connect]);
  
  return {
    ...state,
    connect,
    updateBalance,
  };
}

IPFS Integration for NFT Metadata

Create a service to upload files and metadata to IPFS using Pinata:

// src/lib/ipfs.ts

const PINATA_API_KEY = process.env.NEXT_PUBLIC_PINATA_API_KEY || "";
const PINATA_SECRET_KEY = process.env.PINATA_SECRET_KEY || "";

interface PinataResponse {
  IpfsHash: string;
  PinSize: number;
  Timestamp: string;
}

export class IPFSService {
  private apiUrl = "https://api.pinata.cloud";
  
  async uploadFile(file: File): Promise<string> {
    const formData = new FormData();
    formData.append("file", file);
    
    const response = await fetch(`${this.apiUrl}/pinning/pinFileToIPFS`, {
      method: "POST",
      headers: {
        pinata_api_key: PINATA_API_KEY,
        pinata_secret_api_key: PINATA_SECRET_KEY,
      },
      body: formData,
    });
    
    if (!response.ok) {
      throw new Error("Failed to upload file to IPFS");
    }
    
    const data: PinataResponse = await response.json();
    return `ipfs://${data.IpfsHash}`;
  }
  
  async uploadMetadata(metadata: {
    name: string;
    description: string;
    image: string;
    attributes: Array<{ trait_type: string; value: string | number }>;
  }): Promise<string> {
    const response = await fetch(`${this.apiUrl}/pinning/pinJSONToIPFS`, {
      method: "POST",
      headers: {
        "Content-Type": "application/json",
        pinata_api_key: PINATA_API_KEY,
        pinata_secret_api_key: PINATA_SECRET_KEY,
      },
      body: JSON.stringify({
        pinataContent: metadata,
        pinataOptions: {
          cidVersion: 1,
        },
      }),
    });
    
    if (!response.ok) {
      throw new Error("Failed to upload metadata to IPFS");
    }
    
    const data: PinataResponse = await response.json();
    return `ipfs://${data.IpfsHash}`;
  }
  
  getGatewayUrl(ipfsUri: string): string {
    return ipfsUri.replace("ipfs://", "https://gateway.pinata.cloud/ipfs/");
  }
}

export const ipfsService = new IPFSService();

Security Best Practices

When developing smart contracts, security is paramount. Here are essential security considerations:

1. Reentrancy Protection

Always use the Checks-Effects-Interactions pattern:

// Vulnerable
function withdraw() external {
    uint256 balance = balances[msg.sender];
    (bool success, ) = msg.sender.call{value: balance}("");
    balances[msg.sender] = 0; // State updated after external call
}

// Secure
function withdraw() external nonReentrant {
    uint256 balance = balances[msg.sender];
    balances[msg.sender] = 0; // State updated before external call
    (bool success, ) = msg.sender.call{value: balance}("");
    require(success, "Transfer failed");
}

2. Access Control

Implement proper access controls for sensitive functions:

import "@openzeppelin/contracts/access/AccessControl.sol";

contract SecureContract is AccessControl {
    bytes32 public constant ADMIN_ROLE = keccak256("ADMIN_ROLE");
    bytes32 public constant OPERATOR_ROLE = keccak256("OPERATOR_ROLE");
    
    constructor() {
        _grantRole(DEFAULT_ADMIN_ROLE, msg.sender);
        _grantRole(ADMIN_ROLE, msg.sender);
    }
    
    function sensitiveOperation() external onlyRole(ADMIN_ROLE) {
        // Only admins can call this
    }
}

3. Input Validation

Always validate all inputs:

function transfer(address to, uint256 amount) external {
    require(to != address(0), "Cannot transfer to zero address");
    require(amount > 0, "Amount must be greater than zero");
    require(amount <= balances[msg.sender], "Insufficient balance");
    // ... rest of function
}

Gas Optimization Techniques

Optimizing gas costs improves user experience and reduces transaction fees:

// Use uint256 instead of smaller uints (EVM operates on 256-bit words)
uint256 public counter; // More efficient than uint8

// Pack struct variables to use fewer storage slots
struct Efficient {
    uint128 value1;  // Slot 0
    uint128 value2;  // Slot 0 (packed)
    uint256 value3;  // Slot 1
}

// Use calldata instead of memory for read-only arrays
function processItems(uint256[] calldata items) external pure {
    // calldata is cheaper than memory
}

// Cache array length in loops
function sum(uint256[] memory arr) public pure returns (uint256) {
    uint256 total = 0;
    uint256 length = arr.length; // Cache length
    for (uint256 i = 0; i < length; i++) {
        total += arr[i];
    }
    return total;
}

// Use unchecked for safe arithmetic
function incrementCounter() external {
    unchecked {
        counter++; // Safe if we know it won't overflow
    }
}

Conclusion

Congratulations! You've learned how to build a complete decentralized application from scratch. We covered:

  1. Smart Contract Development: Writing secure, optimized Solidity code
  2. Testing: Comprehensive test suites for reliability
  3. Deployment: Deploying to testnets and mainnets
  4. Frontend Integration: Connecting React apps to the blockchain
  5. IPFS: Decentralized storage for NFT metadata
  6. Security: Best practices for secure smart contracts
  7. Gas Optimization: Reducing transaction costs

Key Takeaways

  • Always prioritize security in smart contract development
  • Test thoroughly before deploying to mainnet
  • Optimize for gas efficiency to improve UX
  • Use established patterns and audited libraries
  • Keep learning as the ecosystem evolves rapidly

Next Steps

  1. Audit your contracts: Consider professional security audits
  2. Add more features: Auctions, offers, collections
  3. Implement subgraph: For efficient data indexing
  4. Deploy to mainnet: When ready for production
  5. Build a community: Marketing and user acquisition

The Web3 space is evolving rapidly, and the skills you've learned here provide a solid foundation for building the decentralized future.


This tutorial is part of the PlayHve Web3 Development series. Join our community to connect with other builders and stay updated on the latest developments.

Written by