Project 2: Nft Marketplace

Project 2: NFT Marketplace with Royalties

Overview

In this project, you'll build a complete NFT marketplace that supports minting, listing, buying, and selling NFTs with royalty payments to original creators. This project combines concepts from multiple modules, including ERC721 tokens, marketplace functionality, security considerations, and gas optimization.

Learning Objectives

Project Structure

nft-marketplace/
├── contracts/
│   ├── NFT.sol
│   ├── Marketplace.sol
│   └── RoyaltyRegistry.sol
├── test/
│   ├── NFT.test.js
│   ├── Marketplace.test.js
│   └── integration.test.js
├── scripts/
│   ├── deploy.js
│   └── setup.js
└── README.md

Step 1: Setting Up the Development Environment

  1. Install Node.js and npm if you haven't already
  2. Create a new directory for your project
  3. Initialize a new Hardhat project:
mkdir nft-marketplace
cd nft-marketplace
npm init -y
npm install --save-dev hardhat @nomiclabs/hardhat-waffle ethereum-waffle chai @nomiclabs/hardhat-ethers ethers @openzeppelin/contracts
npx hardhat
  1. Select "Create a basic sample project" when prompted
  2. Configure your hardhat.config.js file:
require("@nomiclabs/hardhat-waffle");

module.exports = {
  solidity: {
    version: "0.8.17",
    settings: {
      optimizer: {
        enabled: true,
        runs: 200
      }
    }
  },
  networks: {
    hardhat: {
    },
    goerli: {
      url: "https://goerli.infura.io/v3/YOUR_INFURA_KEY",
      accounts: ["YOUR_PRIVATE_KEY"]
    }
  }
};

Step 2: Implementing the NFT Contract

Create a file named NFT.sol in the contracts directory:

// SPDX-License-Identifier: MIT
pragma solidity 0.8.17;

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

/**
 * @title MarketplaceNFT
 * @dev ERC721 token with storage based token URI management, enumeration, and royalty support
 */
contract MarketplaceNFT is ERC721URIStorage, ERC721Enumerable, ERC2981, Ownable, ReentrancyGuard {
    using Counters for Counters.Counter;

    // Token ID counter
    Counters.Counter private _tokenIdCounter;

    // Base URI for metadata
    string private _baseTokenURI;

    // Mapping from token ID to creator address
    mapping(uint256 => address) private _creators;

    // Events
    event NFTMinted(uint256 indexed tokenId, address indexed creator, string tokenURI, uint96 royaltyFee);

    /**
     * @dev Constructor
     * @param name The name of the token
     * @param symbol The symbol of the token
     * @param baseTokenURI The base URI for token metadata
     */
    constructor(
        string memory name,
        string memory symbol,
        string memory baseTokenURI
    ) ERC721(name, symbol) {
        _baseTokenURI = baseTokenURI;
    }

    /**
     * @dev Mint a new NFT
     * @param to The address that will own the minted token
     * @param tokenURI The token URI for the new token
     * @param royaltyFee The royalty fee in basis points (e.g., 250 = 2.5%)
     * @return tokenId The ID of the newly minted token
     */
    function mint(
        address to,
        string memory tokenURI,
        uint96 royaltyFee
    ) public nonReentrant returns (uint256) {
        require(royaltyFee <= 1000, "Royalty fee cannot exceed 10%");

        // Get the current token ID
        uint256 tokenId = _tokenIdCounter.current();

        // Increment the token ID for the next mint
        _tokenIdCounter.increment();

        // Mint the token
        _safeMint(to, tokenId);

        // Set the token URI
        _setTokenURI(tokenId, tokenURI);

        // Set the royalty info
        _setTokenRoyalty(tokenId, to, royaltyFee);

        // Record the creator
        _creators[tokenId] = to;

        emit NFTMinted(tokenId, to, tokenURI, royaltyFee);

        return tokenId;
    }

    /**
     * @dev Get the creator of a token
     * @param tokenId The ID of the token
     * @return The address of the creator
     */
    function getCreator(uint256 tokenId) external view returns (address) {
        require(_exists(tokenId), "Token does not exist");
        return _creators[tokenId];
    }

    /**
     * @dev Set the base URI for all token IDs
     * @param baseTokenURI The new base URI
     */
    function setBaseURI(string memory baseTokenURI) external onlyOwner {
        _baseTokenURI = baseTokenURI;
    }

    /**
     * @dev Base URI for computing {tokenURI}
     * @return The base URI
     */
    function _baseURI() internal view override returns (string memory) {
        return _baseTokenURI;
    }

    /**
     * @dev See {IERC165-supportsInterface}
     */
    function supportsInterface(bytes4 interfaceId)
        public
        view
        override(ERC721, ERC721Enumerable, ERC2981)
        returns (bool)
    {
        return super.supportsInterface(interfaceId);
    }

    /**
     * @dev Hook that is called before any token transfer
     */
    function _beforeTokenTransfer(
        address from,
        address to,
        uint256 tokenId
    ) internal override(ERC721, ERC721Enumerable) {
        super._beforeTokenTransfer(from, to, tokenId);
    }

    /**
     * @dev Hook that is called after a token is burned
     */
    function _burn(uint256 tokenId) internal override(ERC721, ERC721URIStorage) {
        super._burn(tokenId);

        // Clear royalty information
        _resetTokenRoyalty(tokenId);

        // Clear creator information
        delete _creators[tokenId];
    }

    /**
     * @dev See {IERC721Metadata-tokenURI}
     */
    function tokenURI(uint256 tokenId)
        public
        view
        override(ERC721, ERC721URIStorage)
        returns (string memory)
    {
        return super.tokenURI(tokenId);
    }
}

Step 3: Implementing the Marketplace Contract

Create a file named Marketplace.sol in the contracts directory:

// SPDX-License-Identifier: MIT
pragma solidity 0.8.17;

import "@openzeppelin/contracts/token/ERC721/IERC721.sol";
import "@openzeppelin/contracts/token/ERC721/utils/ERC721Holder.sol";
import "@openzeppelin/contracts/token/common/ERC2981.sol";
import "@openzeppelin/contracts/security/ReentrancyGuard.sol";
import "@openzeppelin/contracts/access/Ownable.sol";
import "@openzeppelin/contracts/utils/Counters.sol";

/**
 * @title NFTMarketplace
 * @dev A marketplace for buying and selling NFTs with royalty support
 */
contract NFTMarketplace is ERC721Holder, ReentrancyGuard, Ownable {
    using Counters for Counters.Counter;

    // Listing counter
    Counters.Counter private _listingIdCounter;

    // Marketplace fee in basis points (e.g., 250 = 2.5%)
    uint256 public marketplaceFee;

    // Listing status enum
    enum ListingStatus { Active, Sold, Cancelled }

    // Listing struct
    struct Listing {
        uint256 id;
        address nftContract;
        uint256 tokenId;
        address seller;
        uint256 price;
        ListingStatus status;
        uint256 createdAt;
    }

    // Mapping from listing ID to Listing
    mapping(uint256 => Listing) public listings;

    // Events
    event ListingCreated(
        uint256 indexed listingId,
        address indexed nftContract,
        uint256 indexed tokenId,
        address seller,
        uint256 price
    );

    event ListingSold(
        uint256 indexed listingId,
        address indexed nftContract,
        uint256 indexed tokenId,
        address seller,
        address buyer,
        uint256 price
    );

    event ListingCancelled(
        uint256 indexed listingId,
        address indexed nftContract,
        uint256 indexed tokenId,
        address seller
    );

    event MarketplaceFeeUpdated(uint256 newFee);

    /**
     * @dev Constructor
     * @param _marketplaceFee The marketplace fee in basis points
     */
    constructor(uint256 _marketplaceFee) {
        require(_marketplaceFee <= 1000, "Marketplace fee cannot exceed 10%");
        marketplaceFee = _marketplaceFee;
    }

    /**
     * @dev Create a new listing
     * @param nftContract The address of the NFT contract
     * @param tokenId The ID of the token to list
     * @param price The price of the listing
     * @return listingId The ID of the newly created listing
     */
    function createListing(
        address nftContract,
        uint256 tokenId,
        uint256 price
    ) external nonReentrant returns (uint256) {
        require(price > 0, "Price must be greater than zero");

        // Get the NFT contract
        IERC721 nft = IERC721(nftContract);

        // Check that the sender owns the token
        require(nft.ownerOf(tokenId) == msg.sender, "Not the owner of the token");

        // Check that the marketplace is approved to transfer the token
        require(
            nft.getApproved(tokenId) == address(this) || 
            nft.isApprovedForAll(msg.sender, address(this)),
            "Marketplace not approved to transfer token"
        );

        // Get the next listing ID
        uint256 listingId = _listingIdCounter.current();
        _listingIdCounter.increment();

        // Create the listing
        listings[listingId] = Listing({
            id: listingId,
            nftContract: nftContract,
            tokenId: tokenId,
            seller: msg.sender,
            price: price,
            status: ListingStatus.Active,
            createdAt: block.timestamp
        });

        // Transfer the NFT to the marketplace
        nft.safeTransferFrom(msg.sender, address(this), tokenId);

        emit ListingCreated(listingId, nftContract, tokenId, msg.sender, price);

        return listingId;
    }

    /**
     * @dev Buy a listed NFT
     * @param listingId The ID of the listing to buy
     */
    function buyListing(uint256 listingId) external payable nonReentrant {
        Listing storage listing = listings[listingId];

        // Check that the listing exists and is active
        require(listing.status == ListingStatus.Active, "Listing is not active");

        // Check that the buyer is not the seller
        require(msg.sender != listing.seller, "Cannot buy your own listing");

        // Check that the correct amount was sent
        require(msg.value == listing.price, "Incorrect price");

        // Mark the listing as sold
        listing.status = ListingStatus.Sold;

        // Get the NFT contract
        IERC721 nft = IERC721(listing.nftContract);

        // Calculate fees
        uint256 marketplaceFeeAmount = (listing.price * marketplaceFee) / 10000;
        uint256 remainingAmount = listing.price - marketplaceFeeAmount;

        // Check if the NFT contract supports royalties
        try ERC2981(listing.nftContract).royaltyInfo(listing.tokenId, listing.price) returns (address receiver, uint256 royaltyAmount) {
            if (royaltyAmount > 0 && receiver != address(0)) {
                // Pay royalties
                (bool royaltySuccess, ) = payable(receiver).call{value: royaltyAmount}("");
                require(royaltySuccess, "Failed to pay royalties");

                // Adjust remaining amount
                remainingAmount -= royaltyAmount;
            }
        } catch {
            // No royalty support, continue
        }

        // Pay the seller
        (bool sellerSuccess, ) = payable(listing.seller).call{value: remainingAmount}("");
        require(sellerSuccess, "Failed to pay seller");

        // Transfer the NFT to the buyer
        nft.safeTransferFrom(address(this), msg.sender, listing.tokenId);

        emit ListingSold(
            listingId,
            listing.nftContract,
            listing.tokenId,
            listing.seller,
            msg.sender,
            listing.price
        );
    }

    /**
     * @dev Cancel a listing
     * @param listingId The ID of the listing to cancel
     */
    function cancelListing(uint256 listingId) external nonReentrant {
        Listing storage listing = listings[listingId];

        // Check that the listing exists and is active
        require(listing.status == ListingStatus.Active, "Listing is not active");

        // Check that the sender is the seller
        require(msg.sender == listing.seller, "Not the seller");

        // Mark the listing as cancelled
        listing.status = ListingStatus.Cancelled;

        // Get the NFT contract
        IERC721 nft = IERC721(listing.nftContract);

        // Transfer the NFT back to the seller
        nft.safeTransferFrom(address(this), listing.seller, listing.tokenId);

        emit ListingCancelled(
            listingId,
            listing.nftContract,
            listing.tokenId,
            listing.seller
        );
    }

    /**
     * @dev Update the marketplace fee
     * @param _marketplaceFee The new marketplace fee in basis points
     */
    function updateMarketplaceFee(uint256 _marketplaceFee) external onlyOwner {
        require(_marketplaceFee <= 1000, "Marketplace fee cannot exceed 10%");
        marketplaceFee = _marketplaceFee;

        emit MarketplaceFeeUpdated(_marketplaceFee);
    }

    /**
     * @dev Withdraw accumulated marketplace fees
     */
    function withdrawFees() external onlyOwner {
        uint256 balance = address(this).balance;
        require(balance > 0, "No fees to withdraw");

        (bool success, ) = payable(owner()).call{value: balance}("");
        require(success, "Failed to withdraw fees");
    }

    /**
     * @dev Get a listing by ID
     * @param listingId The ID of the listing
     * @return The listing
     */
    function getListing(uint256 listingId) external view returns (Listing memory) {
        return listings[listingId];
    }

    /**
     * @dev Get the total number of listings
     * @return The total number of listings
     */
    function getListingCount() external view returns (uint256) {
        return _listingIdCounter.current();
    }

    /**
     * @dev Get active listings within a range
     * @param start The start index
     * @param count The number of listings to return
     * @return Array of active listings
     */
    function getActiveListings(uint256 start, uint256 count) external view returns (Listing[] memory) {
        uint256 totalCount = _listingIdCounter.current();

        // Adjust count if it exceeds the total
        if (start >= totalCount) {
            return new Listing[](0);
        }

        if (start + count > totalCount) {
            count = totalCount - start;
        }

        // Count active listings in the range
        uint256 activeCount = 0;
        for (uint256 i = start; i < start + count; i++) {
            if (listings[i].status == ListingStatus.Active) {
                activeCount++;
            }
        }

        // Create result array
        Listing[] memory result = new Listing[](activeCount);

        // Fill result array
        uint256 resultIndex = 0;
        for (uint256 i = start; i < start + count; i++) {
            if (listings[i].status == ListingStatus.Active) {
                result[resultIndex] = listings[i];
                resultIndex++;
            }
        }

        return result;
    }

    /**
     * @dev Get listings by seller
     * @param seller The address of the seller
     * @return Array of listings by the seller
     */
    function getListingsBySeller(address seller) external view returns (Listing[] memory) {
        uint256 totalCount = _listingIdCounter.current();

        // Count listings by seller
        uint256 sellerCount = 0;
        for (uint256 i = 0; i < totalCount; i++) {
            if (listings[i].seller == seller) {
                sellerCount++;
            }
        }

        // Create result array
        Listing[] memory result = new Listing[](sellerCount);

        // Fill result array
        uint256 resultIndex = 0;
        for (uint256 i = 0; i < totalCount; i++) {
            if (listings[i].seller == seller) {
                result[resultIndex] = listings[i];
                resultIndex++;
            }
        }

        return result;
    }

    /**
     * @dev Receive function to accept ETH
     */
    receive() external payable {}
}

Step 4: Implementing the Royalty Registry Contract (Optional)

For enhanced royalty support, you can create a registry that tracks royalties for NFTs that don't implement the ERC2981 standard:

// SPDX-License-Identifier: MIT
pragma solidity 0.8.17;

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

/**
 * @title RoyaltyRegistry
 * @dev A registry for tracking royalties for NFTs that don't implement ERC2981
 */
contract RoyaltyRegistry is Ownable {
    // Royalty info struct
    struct RoyaltyInfo {
        address receiver;
        uint96 royaltyFee; // in basis points (e.g., 250 = 2.5%)
    }

    // Mapping from NFT contract to token ID to royalty info
    mapping(address => mapping(uint256 => RoyaltyInfo)) private _royalties;

    // Mapping from NFT contract to default royalty info
    mapping(address => RoyaltyInfo) private _defaultRoyalties;

    // Events
    event RoyaltySet(address indexed nftContract, uint256 indexed tokenId, address receiver, uint96 royaltyFee);
    event DefaultRoyaltySet(address indexed nftContract, address receiver, uint96 royaltyFee);

    /**
     * @dev Set royalty info for a specific token
     * @param nftContract The address of the NFT contract
     * @param tokenId The ID of the token
     * @param receiver The address that should receive royalties
     * @param royaltyFee The royalty fee in basis points
     */
    function setRoyalty(
        address nftContract,
        uint256 tokenId,
        address receiver,
        uint96 royaltyFee
    ) external {
        require(royaltyFee <= 1000, "Royalty fee cannot exceed 10%");

        // Only the owner of this contract or the NFT contract can set royalties
        require(
            msg.sender == owner() || msg.sender == nftContract,
            "Not authorized to set royalties"
        );

        _royalties[nftContract][tokenId] = RoyaltyInfo(receiver, royaltyFee);

        emit RoyaltySet(nftContract, tokenId, receiver, royaltyFee);
    }

    /**
     * @dev Set default royalty info for an NFT contract
     * @param nftContract The address of the NFT contract
     * @param receiver The address that should receive royalties
     * @param royaltyFee The royalty fee in basis points
     */
    function setDefaultRoyalty(
        address nftContract,
        address receiver,
        uint96 royaltyFee
    ) external {
        require(royaltyFee <= 1000, "Royalty fee cannot exceed 10%");

        // Only the owner of this contract or the NFT contract can set royalties
        require(
            msg.sender == owner() || msg.sender == nftContract,
            "Not authorized to set royalties"
        );

        _defaultRoyalties[nftContract] = RoyaltyInfo(receiver, royaltyFee);

        emit DefaultRoyaltySet(nftContract, receiver, royaltyFee);
    }

    /**
     * @dev Get royalty info for a token
     * @param nftContract The address of the NFT contract
     * @param tokenId The ID of the token
     * @param salePrice The sale price of the token
     * @return receiver The address that should receive royalties
     * @return royaltyAmount The royalty amount
     */
    function royaltyInfo(
        address nftContract,
        uint256 tokenId,
        uint256 salePrice
    ) external view returns (address receiver, uint256 royaltyAmount) {
        RoyaltyInfo memory royalty = _royalties[nftContract][tokenId];

        // If no specific royalty is set for the token, use the default for the contract
        if (royalty.receiver == address(0)) {
            royalty = _defaultRoyalties[nftContract];
        }

        // If no royalty is set, return zero
        if (royalty.receiver == address(0)) {
            return (address(0), 0);
        }

        // Calculate royalty amount
        royaltyAmount = (salePrice * royalty.royaltyFee) / 10000;

        return (royalty.receiver, royaltyAmount);
    }

    /**
     * @dev Check if a token has royalty info
     * @param nftContract The address of the NFT contract
     * @param tokenId The ID of the token
     * @return Whether the token has royalty info
     */
    function hasRoyaltyInfo(address nftContract, uint256 tokenId) external view returns (bool) {
        return _royalties[nftContract][tokenId].receiver != address(0) || 
               _defaultRoyalties[nftContract].receiver != address(0);
    }
}

Step 5: Writing Tests

Create test files for each contract to ensure they work as expected. Here's an example for the NFT contract:

// test/NFT.test.js
const { expect } = require("chai");
const { ethers } = require("hardhat");

describe("MarketplaceNFT", function () {
  let nft;
  let owner;
  let creator;
  let buyer;

  const name = "Marketplace NFT";
  const symbol = "MNFT";
  const baseURI = "https://api.example.com/metadata/";
  const tokenURI = "1";
  const royaltyFee = 250; // 2.5%

  beforeEach(async function () {
    [owner, creator, buyer] = await ethers.getSigners();

    const NFT = await ethers.getContractFactory("MarketplaceNFT");
    nft = await NFT.deploy(name, symbol, baseURI);
    await nft.deployed();
  });

  describe("Deployment", function () {
    it("Should set the right owner", async function () {
      expect(await nft.owner()).to.equal(owner.address);
    });

    it("Should set the correct name and symbol", async function () {
      expect(await nft.name()).to.equal(name);
      expect(await nft.symbol()).to.equal(symbol);
    });
  });

  describe("Minting", function () {
    it("Should mint a new token", async function () {
      await nft.connect(creator).mint(creator.address, tokenURI, royaltyFee);

      expect(await nft.balanceOf(creator.address)).to.equal(1);
      expect(await nft.ownerOf(0)).to.equal(creator.address);
      expect(await nft.tokenURI(0)).to.equal(baseURI + tokenURI);
    });

    it("Should set the correct creator", async function () {
      await nft.connect(creator).mint(creator.address, tokenURI, royaltyFee);

      expect(await nft.getCreator(0)).to.equal(creator.address);
    });

    it("Should set the correct royalty info", async function () {
      await nft.connect(creator).mint(creator.address, tokenURI, royaltyFee);

      const [receiver, amount] = await nft.royaltyInfo(0, 10000);
      expect(receiver).to.equal(creator.address);
      expect(amount).to.equal(250); // 2.5% of 10000
    });

    it("Should fail if royalty fee exceeds 10%", async function () {
      await expect(
        nft.connect(creator).mint(creator.address, tokenURI, 1100)
      ).to.be.revertedWith("Royalty fee cannot exceed 10%");
    });
  });

  describe("Token URI", function () {
    it("Should return the correct token URI", async function () {
      await nft.connect(creator).mint(creator.address, tokenURI, royaltyFee);

      expect(await nft.tokenURI(0)).to.equal(baseURI + tokenURI);
    });

    it("Should update the base URI", async function () {
      await nft.connect(creator).mint(creator.address, tokenURI, royaltyFee);

      const newBaseURI = "https://new.example.com/metadata/";
      await nft.setBaseURI(newBaseURI);

      expect(await nft.tokenURI(0)).to.equal(newBaseURI + tokenURI);
    });
  });

  describe("Transfers", function () {
    beforeEach(async function () {
      await nft.connect(creator).mint(creator.address, tokenURI, royaltyFee);
    });

    it("Should transfer the token", async function () {
      await nft.connect(creator).transferFrom(creator.address, buyer.address, 0);

      expect(await nft.ownerOf(0)).to.equal(buyer.address);
    });

    it("Should maintain creator info after transfer", async function () {
      await nft.connect(creator).transferFrom(creator.address, buyer.address, 0);

      expect(await nft.getCreator(0)).to.equal(creator.address);
    });

    it("Should maintain royalty info after transfer", async function () {
      await nft.connect(creator).transferFrom(creator.address, buyer.address, 0);

      const [receiver, amount] = await nft.royaltyInfo(0, 10000);
      expect(receiver).to.equal(creator.address);
      expect(amount).to.equal(250); // 2.5% of 10000
    });
  });
});

And here's an example for the Marketplace contract:

// test/Marketplace.test.js
const { expect } = require("chai");
const { ethers } = require("hardhat");

describe("NFTMarketplace", function () {
  let nft;
  let marketplace;
  let owner;
  let seller;
  let buyer;

  const name = "Marketplace NFT";
  const symbol = "MNFT";
  const baseURI = "https://api.example.com/metadata/";
  const tokenURI = "1";
  const royaltyFee = 250; // 2.5%
  const marketplaceFee = 250; // 2.5%
  const price = ethers.utils.parseEther("1"); // 1 ETH

  beforeEach(async function () {
    [owner, seller, buyer] = await ethers.getSigners();

    // Deploy NFT contract
    const NFT = await ethers.getContractFactory("MarketplaceNFT");
    nft = await NFT.deploy(name, symbol, baseURI);
    await nft.deployed();

    // Deploy Marketplace contract
    const Marketplace = await ethers.getContractFactory("NFTMarketplace");
    marketplace = await Marketplace.deploy(marketplaceFee);
    await marketplace.deployed();

    // Mint an NFT for the seller
    await nft.connect(seller).mint(seller.address, tokenURI, royaltyFee);

    // Approve the marketplace to transfer the NFT
    await nft.connect(seller).setApprovalForAll(marketplace.address, true);
  });

  describe("Deployment", function () {
    it("Should set the right owner", async function () {
      expect(await marketplace.owner()).to.equal(owner.address);
    });

    it("Should set the correct marketplace fee", async function () {
      expect(await marketplace.marketplaceFee()).to.equal(marketplaceFee);
    });
  });

  describe("Listing", function () {
    it("Should create a listing", async function () {
      await marketplace.connect(seller).createListing(nft.address, 0, price);

      const listing = await marketplace.getListing(0);
      expect(listing.nftContract).to.equal(nft.address);
      expect(listing.tokenId).to.equal(0);
      expect(listing.seller).to.equal(seller.address);
      expect(listing.price).to.equal(price);
      expect(listing.status).to.equal(0); // Active
    });

    it("Should transfer the NFT to the marketplace", async function () {
      await marketplace.connect(seller).createListing(nft.address, 0, price);

      expect(await nft.ownerOf(0)).to.equal(marketplace.address);
    });

    it("Should fail if the price is zero", async function () {
      await expect(
        marketplace.connect(seller).createListing(nft.address, 0, 0)
      ).to.be.revertedWith("Price must be greater than zero");
    });

    it("Should fail if the sender is not the owner of the token", async function () {
      await expect(
        marketplace.connect(buyer).createListing(nft.address, 0, price)
      ).to.be.revertedWith("Not the owner of the token");
    });
  });

  describe("Buying", function () {
    beforeEach(async function () {
      await marketplace.connect(seller).createListing(nft.address, 0, price);
    });

    it("Should allow buying a listed NFT", async function () {
      await marketplace.connect(buyer).buyListing(0, { value: price });

      const listing = await marketplace.getListing(0);
      expect(listing.status).to.equal(1); // Sold
      expect(await nft.ownerOf(0)).to.equal(buyer.address);
    });

    it("Should distribute funds correctly", async function () {
      const initialSellerBalance = await ethers.provider.getBalance(seller.address);
      const initialMarketplaceBalance = await ethers.provider.getBalance(marketplace.address);

      await marketplace.connect(buyer).buyListing(0, { value: price });

      // Calculate expected amounts
      const marketplaceFeeAmount = price.mul(marketplaceFee).div(10000);
      const royaltyAmount = price.mul(royaltyFee).div(10000);
      const sellerAmount = price.sub(marketplaceFeeAmount).sub(royaltyAmount);

      // Check marketplace balance
      const finalMarketplaceBalance = await ethers.provider.getBalance(marketplace.address);
      expect(finalMarketplaceBalance.sub(initialMarketplaceBalance)).to.equal(marketplaceFeeAmount);

      // Check seller balance (approximately due to gas costs)
      const finalSellerBalance = await ethers.provider.getBalance(seller.address);
      const sellerDelta = finalSellerBalance.sub(initialSellerBalance);

      // Allow for a small margin of error due to gas costs
      expect(sellerDelta).to.be.closeTo(sellerAmount, ethers.utils.parseEther("0.01"));
    });

    it("Should fail if the listing is not active", async function () {
      await marketplace.connect(buyer).buyListing(0, { value: price });

      await expect(
        marketplace.connect(buyer).buyListing(0, { value: price })
      ).to.be.revertedWith("Listing is not active");
    });

    it("Should fail if the sender is the seller", async function () {
      await expect(
        marketplace.connect(seller).buyListing(0, { value: price })
      ).to.be.revertedWith("Cannot buy your own listing");
    });

    it("Should fail if the incorrect price is sent", async function () {
      await expect(
        marketplace.connect(buyer).buyListing(0, { value: price.sub(1) })
      ).to.be.revertedWith("Incorrect price");
    });
  });

  describe("Cancelling", function () {
    beforeEach(async function () {
      await marketplace.connect(seller).createListing(nft.address, 0, price);
    });

    it("Should allow cancelling a listing", async function () {
      await marketplace.connect(seller).cancelListing(0);

      const listing = await marketplace.getListing(0);
      expect(listing.status).to.equal(2); // Cancelled
      expect(await nft.ownerOf(0)).to.equal(seller.address);
    });

    it("Should fail if the listing is not active", async function () {
      await marketplace.connect(seller).cancelListing(0);

      await expect(
        marketplace.connect(seller).cancelListing(0)
      ).to.be.revertedWith("Listing is not active");
    });

    it("Should fail if the sender is not the seller", async function () {
      await expect(
        marketplace.connect(buyer).cancelListing(0)
      ).to.be.revertedWith("Not the seller");
    });
  });

  describe("Marketplace Fee", function () {
    it("Should allow the owner to update the marketplace fee", async function () {
      const newFee = 300; // 3%
      await marketplace.updateMarketplaceFee(newFee);

      expect(await marketplace.marketplaceFee()).to.equal(newFee);
    });

    it("Should fail if the new fee exceeds 10%", async function () {
      await expect(
        marketplace.updateMarketplaceFee(1100)
      ).to.be.revertedWith("Marketplace fee cannot exceed 10%");
    });

    it("Should fail if the sender is not the owner", async function () {
      await expect(
        marketplace.connect(seller).updateMarketplaceFee(300)
      ).to.be.revertedWith("Ownable: caller is not the owner");
    });
  });

  describe("Withdrawing Fees", function () {
    beforeEach(async function () {
      await marketplace.connect(seller).createListing(nft.address, 0, price);
      await marketplace.connect(buyer).buyListing(0, { value: price });
    });

    it("Should allow the owner to withdraw fees", async function () {
      const initialOwnerBalance = await ethers.provider.getBalance(owner.address);
      const marketplaceBalance = await ethers.provider.getBalance(marketplace.address);

      const tx = await marketplace.withdrawFees();
      const receipt = await tx.wait();
      const gasUsed = receipt.gasUsed.mul(receipt.effectiveGasPrice);

      const finalOwnerBalance = await ethers.provider.getBalance(owner.address);

      expect(finalOwnerBalance).to.equal(
        initialOwnerBalance.add(marketplaceBalance).sub(gasUsed)
      );

      expect(await ethers.provider.getBalance(marketplace.address)).to.equal(0);
    });

    it("Should fail if there are no fees to withdraw", async function () {
      await marketplace.withdrawFees();

      await expect(
        marketplace.withdrawFees()
      ).to.be.revertedWith("No fees to withdraw");
    });

    it("Should fail if the sender is not the owner", async function () {
      await expect(
        marketplace.connect(seller).withdrawFees()
      ).to.be.revertedWith("Ownable: caller is not the owner");
    });
  });
});

Step 6: Deployment Scripts

Create a deployment script to deploy your contracts:

// scripts/deploy.js
const { ethers } = require("hardhat");

async function main() {
  // Deploy the NFT contract
  const NFT = await ethers.getContractFactory("MarketplaceNFT");
  const nft = await NFT.deploy(
    "Marketplace NFT",
    "MNFT",
    "https://api.example.com/metadata/"
  );
  await nft.deployed();
  console.log("NFT contract deployed to:", nft.address);

  // Deploy the Marketplace contract
  const Marketplace = await ethers.getContractFactory("NFTMarketplace");
  const marketplaceFee = 250; // 2.5%
  const marketplace = await Marketplace.deploy(marketplaceFee);
  await marketplace.deployed();
  console.log("Marketplace contract deployed to:", marketplace.address);

  // Deploy the RoyaltyRegistry contract (optional)
  const RoyaltyRegistry = await ethers.getContractFactory("RoyaltyRegistry");
  const royaltyRegistry = await RoyaltyRegistry.deploy();
  await royaltyRegistry.deployed();
  console.log("RoyaltyRegistry contract deployed to:", royaltyRegistry.address);

  // Mint some NFTs for testing
  console.log("Minting NFTs for testing...");

  const [owner] = await ethers.getSigners();

  // Mint 3 NFTs
  for (let i = 0; i < 3; i++) {
    const tx = await nft.mint(
      owner.address,
      `${i}`,
      250 // 2.5% royalty
    );
    await tx.wait();
    console.log(`Minted NFT #${i}`);

    // Approve the marketplace to transfer the NFT
    await nft.setApprovalForAll(marketplace.address, true);
  }

  console.log("Setup complete!");
}

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

Step 7: Running the Project

  1. Compile the contracts:
npx hardhat compile
  1. Run the tests:
npx hardhat test
  1. Deploy to a testnet:
npx hardhat run scripts/deploy.js --network goerli

Step 8: Building a Frontend (Optional)

For a complete marketplace, you can build a frontend using React, Next.js, or another framework. Here's a simple example of how to interact with your contracts using ethers.js:

// Connect to the contracts
const nftContract = new ethers.Contract(nftAddress, nftAbi, signer);
const marketplaceContract = new ethers.Contract(marketplaceAddress, marketplaceAbi, signer);

// Mint an NFT
async function mintNFT(tokenURI, royaltyFee) {
  const tx = await nftContract.mint(signer.address, tokenURI, royaltyFee);
  await tx.wait();
  return tx;
}

// Create a listing
async function createListing(tokenId, price) {
  // Approve the marketplace if not already approved
  const isApproved = await nftContract.isApprovedForAll(signer.address, marketplaceAddress);
  if (!isApproved) {
    const approveTx = await nftContract.setApprovalForAll(marketplaceAddress, true);
    await approveTx.wait();
  }

  const tx = await marketplaceContract.createListing(nftAddress, tokenId, ethers.utils.parseEther(price));
  await tx.wait();
  return tx;
}

// Buy a listing
async function buyListing(listingId, price) {
  const tx = await marketplaceContract.buyListing(listingId, {
    value: ethers.utils.parseEther(price)
  });
  await tx.wait();
  return tx;
}

// Cancel a listing
async function cancelListing(listingId) {
  const tx = await marketplaceContract.cancelListing(listingId);
  await tx.wait();
  return tx;
}

// Get active listings
async function getActiveListings() {
  const count = await marketplaceContract.getListingCount();
  return marketplaceContract.getActiveListings(0, count);
}

Conclusion

In this project, you've built a complete NFT marketplace with royalty support:

  1. An ERC721 token contract with metadata, enumeration, and royalty support
  2. A marketplace contract for listing, buying, and selling NFTs
  3. A royalty registry for tracking royalties for NFTs that don't implement ERC2981

You've applied concepts from multiple modules, including:

This project demonstrates how to build a practical and feature-rich NFT marketplace. You can extend it by adding features like auctions, offers, collections, and more sophisticated royalty mechanisms.