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
- Implement the ERC721 standard for non-fungible tokens
- Create a secure marketplace contract for listing and trading NFTs
- Implement royalty mechanisms for creators
- Apply security best practices and design patterns
- Test and deploy the contracts to a testnet
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
- Install Node.js and npm if you haven't already
- Create a new directory for your project
- 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
- Select "Create a basic sample project" when prompted
- Configure your
hardhat.config.jsfile:
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
- Compile the contracts:
npx hardhat compile
- Run the tests:
npx hardhat test
- 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:
- An ERC721 token contract with metadata, enumeration, and royalty support
- A marketplace contract for listing, buying, and selling NFTs
- A royalty registry for tracking royalties for NFTs that don't implement ERC2981
You've applied concepts from multiple modules, including:
- ERC721 token standard (Module 2: Solidity Basics)
- Access control and security (Module 8: Security Considerations)
- Gas optimization (Module 9: Gas Optimization)
- Design patterns (Module 10: Design Patterns)
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.