Skip to main content

Someone wanted to learn this too, so Grasp built them a personal learning path.

You can keep exploring this one or create your own

Building an NFT Marketplace Smart Contract

Hello! Welcome back to your course on Ethereum development.

In our last lesson, we mastered implementing on-chain royalties with ERC-2981, ensuring creators get paid from secondary sales. Now, we'll build the very platform where those sales happen. This lesson is the cornerstone of the NFT ecosystem: the marketplace.

Learning Outcome: By the end of this 60-minute lesson, you will be able to build the smart contract logic for a simple NFT marketplace supporting fixed-price listings and purchases.

We will design and implement the core functions that allow users to list their NFTs for a set price, for others to buy them, and for sellers to cancel their listings. We will focus on security best practices and, importantly, integrate the royalty mechanism we learned about previously.

1. The Marketplace Architecture

At its heart, an NFT marketplace is a smart contract that acts as a trusted intermediary. It doesn't own the NFTs permanently but facilitates their transfer based on a set of rules. The core components we'll build are:

  • A Listing data structure: To hold all the information about an NFT for sale, such as its price and seller.
  • State variables: Mappings to store the listings and track seller earnings.
  • Core functions:
    • listItem: To allow an owner to list their NFT for sale.
    • buyItem: To allow a user to purchase a listed NFT.
    • cancelListing: To allow a seller to remove their listing.
    • withdrawProceeds: To allow a seller to collect their earnings.

Let's start by exploring the fundamental data structures and the listItem function.

How to Build an NFT Marketplace - Solidity Tutorial (for Beginners) [2022]

This video provides a clear, step-by-step walkthrough of creating the basic data structures and the logic for listing a token. It's a great starting point for understanding the flow.

Please watch the following segments: Defining the Listing (08:44 - 12:00): Focus on how a struct is used to define a Listing and how a mapping is used to store these listings. Note the use of an enum for status, which is a good practice. Implementing listToken (05:26 - 08:44 & 12:00 - 14:09): Pay attention to the function's parameters (token, tokenId, price) and how it creates and stores a new listing. The video also discusses function visibility (public, external, etc.), which is a helpful review.

2. Building the Core Functions

The video provides a solid foundation. Now, let's refine this logic using modern, secure development patterns that you would be expected to use in a professional environment. We will structure our contract around three key operations: listing, buying, and canceling.

The Chainlink tutorial you'll read next uses Hardhat (which we've covered) and introduces some excellent patterns like function modifiers and custom errors, which make the code cleaner and more gas-efficient.

How To Build an NFT Marketplace

This Chainlink tutorial outlines a professional-grade structure for an NFT marketplace. We will use its patterns as our blueprint.

Read the sections 'Building the NFT Marketplace', 'Implementing listItem()', and 'Function Modifiers, Events, and State Variables'. Focus on: The Listing struct and s_listings mapping: Notice the key for the mapping is (address nftAddress, uint256 tokenId). This is a robust way to uniquely identify any NFT on the blockchain. Function Modifiers: Understand how notListed and isOwner are used to modularize checks, keeping the main function body clean.

The listItem Function (Secure Pattern)

Combining the logic from the video and the structure from the article, a robust listItem function should perform these steps in order:

  1. Check Conditions (Modifiers):
    • isOwner: Ensure msg.sender owns the NFT.
    • notListed: Ensure the NFT isn't already for sale.
    • require(price > 0): The price must be a positive value.
  2. Handle Approvals & Transfer:
    • The seller must first approve the marketplace contract to manage their NFT.
    • The marketplace then calls transferFrom to take custody of the NFT. This is the escrow model, where the marketplace holds the asset to guarantee it can be delivered upon sale.
  3. Update State:
    • Create a new Listing struct with the price and seller (msg.sender).
    • Store this Listing in the s_listings mapping.
  4. Emit Event:
    • Emit an ItemListed event to notify off-chain applications that a new item is available.

Here is what the function would look like, incorporating these patterns:

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

import "@openzeppelin/contracts/token/ERC721/IERC721.sol";
import "@openzeppelin/contracts/security/ReentrancyGuard.sol";

contract NftMarketplace is ReentrancyGuard {
    // A struct to hold info about a listed NFT
    struct Listing {
        uint256 price;
        address seller;
    }

    // Mapping from NFT Contract Address => Token ID => Listing
    mapping(address => mapping(uint256 => Listing)) private s_listings;

    // Mapping from Seller Address => Earned Proceeds
    mapping(address => uint256) private s_proceeds;

    // Events
    event ItemListed(
        address indexed seller,
        address indexed nftAddress,
        uint256 indexed tokenId,
        uint256 price
    );
    // ... other events will go here

    // --- MODIFIERS ---
    modifier notListed(address nftAddress, uint256 tokenId) {
        Listing memory listing = s_listings[nftAddress][tokenId];
        if (listing.price > 0) {
            revert("AlreadyListed");
        }
        _;
    }

    modifier isOwner(address nftAddress, uint256 tokenId, address spender) {
        IERC721 nft = IERC721(nftAddress);
        if (nft.ownerOf(tokenId) != spender) {
            revert("NotOwner");
        }
        _;
    }

    // --- MAIN FUNCTIONS ---
    function listItem(address nftAddress, uint256 tokenId, uint256 price)
        external
        notListed(nftAddress, tokenId)
        isOwner(nftAddress, tokenId, msg.sender)
    {
        if (price == 0) {
            revert("PriceMustBeAboveZero");
        }

        IERC721 nft = IERC721(nftAddress);
        // The seller must have approved the marketplace contract first
        if (nft.getApproved(tokenId) != address(this)) {
            revert("NotApprovedForMarketplace");
        }

        // The marketplace takes custody of the NFT
        nft.transferFrom(msg.sender, address(this), tokenId);

        s_listings[nftAddress][tokenId] = Listing(price, msg.sender);
        emit ItemListed(msg.sender, nftAddress, tokenId, price);
    }
}

3. Handling Purchases and Payouts

The buyItem function is the most critical part of the contract. It handles the exchange of value—Ether for the NFT—and must be protected against attacks.

A key security principle in Solidity is the "Pull-over-Push" pattern for payments.

  • Push (Bad): The contract immediately tries to push funds to the seller upon purchase. This can fail if the seller is a contract that can't accept Ether, or it can open the door to reentrancy attacks.
  • Pull (Good): The contract updates an internal accounting variable (s_proceeds) with the seller's earnings. The seller must then call a separate withdrawProceeds function to pull their funds out. This isolates the payment logic and is much safer.

Let's see how this is implemented.

How To Build an NFT Marketplace

This section of the Chainlink tutorial explains the buyItem and withdrawProceeds functions, highlighting the pull-over-push pattern and reentrancy protection.

Read the sections 'Implementing BuyItem()' and 'Implementing withdrawProceeds()'. Pay close attention to: How buyItem adds the sale price to s_proceeds instead of transferring it directly. The use of the nonReentrant modifier from OpenZeppelin's ReentrancyGuard. The syntax payable(msg.sender).call{value: proceeds}("") in withdrawProceeds for securely sending Ether.

Integrating Royalties (Connecting to the Last Lesson)

The buyItem function is precisely where we honor the ERC-2981 royalties we implemented in the previous lesson. Before updating the seller's proceeds, the marketplace must query the NFT for royalty information and split the payment accordingly.

The logic flow inside buyItem should be:

  1. Check that msg.value equals the listing price.
  2. Call royaltyInfo on the NFT contract to get the royaltyReceiver and royaltyAmount.
  3. Transfer royaltyAmount to the royaltyReceiver.
  4. Add the remainder (price - royaltyAmount) to the seller's balance in s_proceeds.
  5. Transfer the NFT to the buyer (msg.sender).
  6. Delete the listing.
  7. Emit an ItemBought event.

Now, let's watch a video that walks through the implementation of the buyItem function, which will help visualize this flow.

How to Build an NFT Marketplace - Solidity Tutorial (for Beginners) [2022]

This segment of the video explains the logic for buying a token. While it uses a direct 'push' payment for simplicity, the core logic of checking the price and transferring the NFT is the same. We will then adapt it with our more secure 'pull' pattern.

Watch the 'buyToken' implementation from 14:09 to 28:54. Focus on how the function verifies the payment (msg.value), transfers the NFT to the buyer, and (in this simplified version) pays the seller directly.

4. Canceling Listings and Finalizing the Contract

The final core feature is allowing a seller to cancel their listing. This is a straightforward process: verify the caller is the seller, delete the listing, and return the escrowed NFT.

How to Build an NFT Marketplace - Solidity Tutorial (for Beginners) [2022]

Let's quickly see how the cancelListing function is built.

Watch the 'cancelListing' implementation from 28:54 to 30:48. Note the checks to ensure only the seller can cancel an active listing and how the NFT is transferred back to them.

Now we have all the pieces. Here is the complete, secure NftMarketplace.sol contract incorporating the best practices we've discussed: function modifiers, pull-over-push payments, reentrancy guards, and custom errors (for gas efficiency).

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

import "@openzeppelin/contracts/token/ERC721/IERC721.sol";
import "@openzeppelin/contracts/security/ReentrancyGuard.sol";
import "@openzeppelin/contracts/interfaces/IERC2981.sol"; // For royalties

contract NftMarketplace is ReentrancyGuard {
    struct Listing {
        uint256 price;
        address seller;
    }

    // --- STATE VARIABLES ---
    mapping(address => mapping(uint256 => Listing)) private s_listings;
    mapping(address => uint256) private s_proceeds;

    // --- EVENTS ---
    event ItemListed(address indexed seller, address indexed nftAddress, uint256 indexed tokenId, uint256 price);
    event ItemBought(address indexed buyer, address indexed nftAddress, uint256 indexed tokenId, uint256 price);
    event ItemCanceled(address indexed seller, address indexed nftAddress, uint256 indexed tokenId);

    // --- ERRORS ---
    error AlreadyListed(address nftAddress, uint256 tokenId);
    error NotOwner();
    error NotListed(address nftAddress, uint256 tokenId);
    error PriceMustBeAboveZero();
    error NotApprovedForMarketplace();
    error PriceNotMet(address nftAddress, uint256 tokenId, uint256 price);
    error NoProceeds();
    error TransferFailed();

    // --- MODIFIERS ---
    modifier notListed(address nftAddress, uint256 tokenId) {
        Listing memory listing = s_listings[nftAddress][tokenId];
        if (listing.price > 0) { revert AlreadyListed(nftAddress, tokenId); }
        _;
    }

    modifier isListed(address nftAddress, uint256 tokenId) {
        Listing memory listing = s_listings[nftAddress][tokenId];
        if (listing.price == 0) { revert NotListed(nftAddress, tokenId); }
        _;
    }

    modifier isOwner(address nftAddress, uint256 tokenId, address spender) {
        if (IERC721(nftAddress).ownerOf(tokenId) != spender) { revert NotOwner(); }
        _;
    }

    // --- MAIN FUNCTIONS ---
    function listItem(address nftAddress, uint256 tokenId, uint256 price)
        external
        notListed(nftAddress, tokenId)
        isOwner(nftAddress, tokenId, msg.sender)
    {
        if (price == 0) { revert PriceMustBeAboveZero(); }
        if (IERC721(nftAddress).getApproved(tokenId) != address(this)) { revert NotApprovedForMarketplace(); }
        
        s_listings[nftAddress][tokenId] = Listing(price, msg.sender);
        emit ItemListed(msg.sender, nftAddress, tokenId, price);
        
        // Transfer NFT to marketplace (escrow)
        IERC721(nftAddress).transferFrom(msg.sender, address(this), tokenId);
    }

    function buyItem(address nftAddress, uint256 tokenId)
        external
        payable
        nonReentrant
        isListed(nftAddress, tokenId)
    {
        Listing memory listedItem = s_listings[nftAddress][tokenId];
        if (msg.value != listedItem.price) { revert PriceNotMet(nftAddress, tokenId, listedItem.price); }

        // Royalty Payment Logic from previous lesson
        uint256 royaltyAmount = 0;
        address royaltyReceiver;
        try IERC2981(nftAddress).royaltyInfo(tokenId, listedItem.price) returns (address receiver, uint256 amount) {
            royaltyReceiver = receiver;
            royaltyAmount = amount;
        } catch {
            // No royalties supported, do nothing
        }

        // Pay royalty if applicable
        if (royaltyAmount > 0) {
            (bool success, ) = payable(royaltyReceiver).call{value: royaltyAmount}("");
            if (!success) { revert TransferFailed(); }
        }

        // Add seller's cut to their proceeds balance (Pull pattern)
        s_proceeds[listedItem.seller] += (listedItem.price - royaltyAmount);
        
        // Delete listing and emit event
        delete s_listings[nftAddress][tokenId];
        emit ItemBought(msg.sender, nftAddress, tokenId, listedItem.price);

        // Transfer NFT from marketplace to buyer
        IERC721(nftAddress).transferFrom(address(this), msg.sender, tokenId);
    }

    function cancelListing(address nftAddress, uint256 tokenId)
        external
        nonReentrant
        isListed(nftAddress, tokenId)
        isOwner(nftAddress, tokenId, s_listings[nftAddress][tokenId].seller)
    {
        delete s_listings[nftAddress][tokenId];
        emit ItemCanceled(msg.sender, nftAddress, tokenId);
        
        // Return NFT from escrow
        IERC721(nftAddress).transferFrom(address(this), msg.sender, tokenId);
    }

    function withdrawProceeds() external nonReentrant {
        uint256 proceeds = s_proceeds[msg.sender];
        if (proceeds == 0) { revert NoProceeds(); }
        
        s_proceeds[msg.sender] = 0; // Effects before Interactions
        
        (bool success, ) = payable(msg.sender).call{value: proceeds}("");
        if (!success) { revert TransferFailed(); }
    }

    // --- GETTER FUNCTIONS ---
    function getListing(address nftAddress, uint256 tokenId) external view returns (Listing memory) {
        return s_listings[nftAddress][tokenId];
    }

    function getProceeds(address seller) external view returns (uint256) {
        return s_proceeds[seller];
    }
}

Conclusion

Congratulations! You have now designed and built the smart contract for a fully functional, fixed-price NFT marketplace. You've gone beyond the basics to incorporate professional-grade security patterns that are essential for handling user funds and assets on the blockchain.

Key Takeaways:

  • Core Logic: The marketplace revolves around three functions: listItem, buyItem, and cancelListing.
  • Data Structures: A Listing struct and a nested mapping are effective tools for storing on-chain sales data.
  • Escrow Model: The marketplace takes temporary custody of the NFT during listing to guarantee delivery upon a successful sale.
  • Security First:
    • Pull-over-Push: Using an internal accounting mapping (s_proceeds) and a withdrawProceeds function is safer than direct payment transfers.
    • Reentrancy Guard: OpenZeppelin's ReentrancyGuard is a simple and vital tool to prevent a common class of attacks.
    • Checks-Effects-Interactions: You implicitly followed this pattern by performing checks first, updating state variables (s_proceeds, deleting listings) next, and finally performing external calls (token/Ether transfers).
  • Royalty Integration: You successfully connected this lesson to the last by integrating the ERC-2981 royalty check directly into the buyItem payment flow.

Next Steps:

Fixed-price sales are just one model. To create a more dynamic and exciting marketplace, you need to support bidding. In our next lesson, we will extend the marketplace contract to support a basic auction system, including placing bids and settling the auction.

Can't find a good explanation? Sign up and we'll make it for you

Sign up