NFTs have become a core part of the web3 ecosystem since the 2021 boom, evolving beyond collectibles into real-world use cases like membership access, asset tokenization, gaming items, and digital identity.
No-code platforms like Phosphor have made it easier than ever for creators and developers to mint, customize, and deploy NFTs through an intuitive Creator Studio and API suite, removing the need to build custom NFT contracts from scratch. However, for developers looking to deepen their understanding of Solidity and web3-native experiences, learning how to build an NFT marketplace remains a foundational skill.
In this guide, we’ll walk through the process of developing and deploying a basic but fully functional NFT marketplace on Linea. By the end, you’ll have the knowledge to launch your own marketplace and gain a stronger grasp of smart contract development, decentralized applications, and web3 interactions.
Prerequisite
You should have a basic understanding of Solidity.
You should have Nodejs and Foundry installed on your PC.
Overview of the contract
This NFT marketplace will support the following functionalities:
Listing NFTs for sale: Users can set a price and list their NFTs, with details securely stored in the smart contract.
Buying NFTs: Buyers can purchase listed NFTs by paying the required Ether, ensuring a seamless transaction.
Batch buying NFTs: Multiple NFTs can be purchased in a single transaction to reduce gas costs.
Delisting NFTs: Owners can remove their NFTs from sale, returning them to their wallets.
Project set up
We will be using the Foundry framework for this project. With Foundry installed, run the command below to initiate a new project:
forge init market
Open the market folder on Vscode or your favorite code editor, and delete the scripts/counter.s.sol, src/counter.sol, and test/counter.t.sol
Install all dependencies
forge install foundry-rs/forge-std --no-commit && forge install OpenZeppelin/openzeppelin-contracts --no-commit
Full code of the NFT marketplace smart contract
Create a market.sol
file in the`src` folder and add the code below to the file.
pragma solidity ^0.8.18;
import "lib/openzeppelin-contracts/contracts/token/ERC721/IERC721.sol";
import {Test, console} from "forge-std/Test.sol";
contract NFTMarketplace {
struct Listing {
address seller;
uint256 price;
}
mapping(address => mapping(uint256 => Listing)) public listings;
event Listed(address indexed nftContract, uint256 indexed tokenId, address seller, uint256 price);
event Purchase(address indexed nftContract, uint256 indexed tokenId, address buyer, uint256 price);
event Delisted(address indexed nftContract, uint256 indexed tokenId, address seller);
modifier isSeller(address nftContract, uint256 tokenId, address spender) {
require(listings[address(nftContract)][tokenId].seller == spender, "Not the seller");
_;
}
modifier isListed(address nftContract, uint256 tokenId) {
require(listings[nftContract][tokenId].price > 0, "Not listed");
_;
}
modifier notListed(address nftContract, uint256 tokenId) {
require(listings[nftContract][tokenId].price == 0, "Already listed");
_;
}
function listNFT(address nftContract, uint256 tokenId, uint256 price)
external
notListed(nftContract, tokenId)
{
require(price > 0, "Price must be greater than zero");
require(IERC721(nftContract).ownerOf(tokenId) == msg.sender, "not owner");
IERC721(nftContract).transferFrom(msg.sender, address(this), tokenId);
listings[nftContract][tokenId] = Listing(msg.sender, price);
emit Listed(nftContract, tokenId, msg.sender, price);
}
function buyNFT(address nftContract, uint256 tokenId)
public
payable
isListed(nftContract, tokenId)
{
Listing memory listing = listings[nftContract][tokenId];
require(msg.value >= listing.price, "Insufficient funds");
delete listings[nftContract][tokenId];
(bool sent, ) = listing.seller.call{value: listing.price}("");
require(sent, "Failed to send Ether");
IERC721(nftContract).transferFrom(address(this), msg.sender, tokenId);
emit Purchase(nftContract, tokenId, msg.sender, listing.price);
}
function buyMultipleNFT(address nftContract, uint256 [] memory tokenIds)
external
payable
{
uint256 totalAmount;
for (uint256 i; i < tokenIds.length; i++){
Listing memory listing = listings[nftContract][tokenIds[i]];
totalAmount += listing.price;
}
require(msg.value >= totalAmount, "Insufficient funds");
for (uint256 i; i < tokenIds.length; i++){
buyNFT(nftContract, tokenIds[i]);
}
}
function delistNFT(address nftContract, uint256 tokenId)
external
isListed(nftContract, tokenId)
isSeller(nftContract, tokenId, msg.sender)
{
Listing memory listing = listings[nftContract][tokenId];
delete listings[nftContract][tokenId];
IERC721(nftContract).transferFrom(address(this), listing.seller, tokenId);
emit Delisted(nftContract, tokenId, listing.seller);
}
}
Key features of the code
Listing struct
This is essential for implementing features like listing, buying, and delisting in a decentralized NFT marketplace. It provides a clear and efficient way to manage listings while ensuring the marketplace keeps a record of each item’s seller and price—both crucial for its operations.
struct Listing {
address seller;
uint256 price;
}
Listing mapping
This line of code defines a nested mapping in Solidity, which organizes and stores listings for the NFT marketplace. The outer mapping uses the NFT contract address as the key, allowing the smart contract to categorize listings by their respective NFT collections. This ensures the marketplace can support multiple NFT collections.
The inner mapping uses the token ID (a unique identifier for each NFT) as the key, enabling efficient management of multiple listings under a single NFT contract.
mapping(address => mapping(uint256 => Listing)) public listings;
NFT listing
The listNFT
function allows users to list their NFTs for sale on the marketplace. It takes three parameters: NFT contract, token ID, and price. The NFT contract refers to the address of the collection from which the user wants to list an NFT, the token ID is the unique identifier of the NFT within that collection, and the price is the amount the user wants to sell the NFT for.
The function first validates that the user is the owner of the NFT and ensures that the price is greater than zero. Once these checks pass, the NFT is transferred from the seller to the marketplace, the listings mapping is updated, and the Listed event is emitted with the correct parameters.
function listNFT(address nftContract, uint256 tokenId, uint256 price)
external
notListed(nftContract, tokenId)
{
require(price > 0, "Price must be greater than zero");
require(IERC721(nftContract).ownerOf(tokenId) == msg.sender, "not owner");
IERC721(nftContract).transferFrom(msg.sender, address(this), tokenId);
listings[nftContract][tokenId] = Listing(msg.sender, price);
emit Listed(nftContract, tokenId, msg.sender, price);
}
Buying NFT
The buyNFT
function allows users to purchase an NFT listed on the marketplace. It takes two parameters: nftContract, the address of the NFT's smart contract, and tokenId, the unique identifier of the NFT being purchased.
The function first verifies that the buyer has sent enough Ether to cover the NFT's price. Once validated, the listing is removed from the listings mapping, the payment is securely transferred to the seller, and the NFT is transferred from the marketplace contract to the buyer's address, finalizing the purchase.
function buyNFT(address nftContract, uint256 tokenId)
public
payable
isListed(nftContract, tokenId)
{
Listing memory listing = listings[nftContract][tokenId];
require(msg.value >= listing.price, "Insufficient funds");
delete listings[nftContract][tokenId];
(bool sent, ) = listing.seller.call{value: listing.price}("");
require(sent, "Failed to send Ether");
IERC721(nftContract).transferFrom(address(this), msg.sender, tokenId);
emit Purchase(nftContract, tokenId, msg.sender, listing.price);
}
Buying multiple NFTs
The buyMultipleNFT function allows a user to purchase multiple NFTs in a single transaction. It first calculates the total price by summing the prices of all NFTs listed in the tokenIds array. The function then verifies that the buyer has sent enough funds to cover the total amount.
Once the funds are validated, the function iterates through the tokenIds array, calling the buyNFT function for each NFT. This ensures that each NFT is purchased individually, the payment is transferred to the seller, and the NFTs are sent to the buyer—following the same process as the buyNFT function.
function buyMultipleNFT(address nftContract, uint256 [] memory tokenIds)
external
payable
{
uint256 totalAmount;
for (uint256 i; i < tokenIds.length; i++){
Listing memory listing = listings[nftContract][tokenIds[i]];
totalAmount += listing.price;
}
require(msg.value >= totalAmount, "Insufficient funds");
for (uint256 i; i < tokenIds.length; i++){
buyNFT(nftContract, tokenIds[i]);
}
}
Delisting NFTs
The delistNFT function allows a seller to remove their NFT from the marketplace. It first verifies that the NFT is listed and that the caller is the rightful owner. Once validated, the listing is deleted from the marketplace, effectively removing the NFT from sale.
The function then transfers the NFT back to the seller, ensuring they regain ownership of their asset. Finally, the Delisted event is emitted to confirm that the NFT has been successfully removed from the marketplace.
function delistNFT(address nftContract, uint256 tokenId)
external
isListed(nftContract, tokenId)
isSeller(nftContract, tokenId, msg.sender)
{
Listing memory listing = listings[nftContract][tokenId];
delete listings[nftContract][tokenId];
IERC721(nftContract).transferFrom(address(this), listing.seller, tokenId);
emit Delisted(nftContract, tokenId, listing.seller);
}
Key events
The contract emits events for essential marketplace actions:
Listed: Triggered when an NFT is listed, recording details such as the seller, price, and NFT information.
Purchase: Emitted when a buyer purchases an NFT, ensuring transaction traceability.
Delisted: Indicates that an NFT has been successfully removed from the marketplace, maintaining transparency.
event Listed(address indexed nftContract, uint256 indexed tokenId, address seller, uint256 price);
event Purchase(address indexed nftContract, uint256 indexed tokenId, address buyer, uint256 price);
event Delisted(address indexed nftContract, uint256 indexed tokenId, address seller
Test suite overview
Create a NFT.sol
file in the src folder and add the code below to the file.
pragma solidity ^0.8.18;
import "lib/openzeppelin-contracts/contracts/token/ERC721/ERC721.sol";
import "lib/openzeppelin-contracts/contracts/access/Ownable.sol";
contract NFT is ERC721, Ownable {
uint256 private _tokenIdCounter;
string private _baseTokenURI;
event Minted(address indexed to, uint256 tokenId);
constructor(string memory name, string memory symbol, string memory baseTokenURI) ERC721(name, symbol) Ownable(msg.sender) {
_baseTokenURI = baseTokenURI;
}
function mint(address to) external onlyOwner {
uint256 tokenId = _tokenIdCounter;
_tokenIdCounter += 1;
_safeMint(to, tokenId);
emit Minted(to, tokenId);
}
function _baseURI() internal view override returns (string memory) {
return _baseTokenURI;
}
function setBaseTokenURI(string memory newBaseTokenURI) external onlyOwner {
_baseTokenURI = newBaseTokenURI;
}
function getTokenCount() external view returns (uint256) {
return _tokenIdCounter;
}
}
Create a market.t.sol file in the test folder and add the code below to the file.
pragma solidity ^0.8.13;
import {Test, console} from "forge-std/Test.sol";
import {NFTMarketplace} from "../src/Market.sol";
import {NFT} from "../src/NFT.sol";
contract MarketTest is Test {
NFTMarketplace public market;
NFT public nft;
address public user1 = makeAddr("user1");
address public user2 = makeAddr("user2");
address public user3 = makeAddr("user3");
function setUp() public {
nft = new NFT("TestNFT", "tnft", "");
market = new NFTMarketplace();
nft.mint(user1);
nft.mint(user1);
nft.mint(user1);
}
function test_Listing() public {
vm.startPrank(user1);
nft.approve(address(market), 0);
market.listNFT(address(nft), 0, 1 ether);
}
function test_BuyingNFT() public {
vm.startPrank(user1);
nft.approve(address(market), 0);
market.listNFT(address(nft), 0, 1 ether);
vm.startPrank(user2);
deal(user2, 10 ether);
market.buyNFT{value: 1 ether}(address(nft), 0);
assert(nft.ownerOf(0) == user2);
}
function test_BuyingMultipleNFT() public {
vm.startPrank(user1);
nft.approve(address(market), 0);
nft.approve(address(market), 1);
nft.approve(address(market), 2);
market.listNFT(address(nft), 0, 1 ether);
market.listNFT(address(nft), 1, 1 ether);
market.listNFT(address(nft), 2, 1 ether);
vm.startPrank(user2);
deal(user2, 10 ether);
uint256 [] memory nfts = new uint256[] (3);
nfts[0] = 0;
nfts[1] = 1;
nfts[2] = 2;
market.buyMultipleNFT{value: 3 ether}(address(nft), nfts);
}
function test_DelistingNFT() public {
vm.startPrank(user1);
nft.approve(address(market), 0);
market.listNFT(address(nft), 0, 1 ether);
assert(nft.ownerOf(0) == address(market));
market.delistNFT(address(nft), 0);
assert(nft.ownerOf(0) == address(user1));
}
}
The test suite above ensures that the NFT marketplace contract operates as intended across its core functionalities. Here’s a breakdown of the tests:
Setting up the testing environment
The setUp
function initializes the environment by deploying both the NFTMarketplace and NFT contracts. It mints three NFTs to user1 for testing purposes. A mock NFT contract (NFT) is deployed with a mint
function to generate NFTs, while the marketplace contract (NFTMarketplace) is instantiated. User1 owns all the minted NFTs, allowing testing of listing and delisting functionalities.
Testing NFT listing
The test_Listing
function ensures that user1 can list an NFT for sale on the marketplace.
User1 approves the marketplace to transfer tokenId 0
.
User1 lists the NFT at a price of 1 Ether.
The test confirms that the listNFT function works correctly and that the NFT is transferred to the marketplace.
Testing single NFT purchase
The test_BuyingNFT
function simulates the purchase of a listed NFT by user2.
User1 lists tokenId 0
for 1 Ether.
User2 receives 10 Ether and calls the buyNFT
function, sending 1 Ether to purchase the NFT.
After the purchase, the test verifies that user2 is the new owner of tokenId 0
.
Testing multiple NFT purchases
The test_BuyingMultipleNFT
function tests the ability to buy multiple NFTs in a single transaction.
User1 lists three NFTs (tokenId 0, 1, 2
) for 1 Ether each.
User2 calls buyMultipleNFT
, passing the token IDs and sending 3 Ether.
The test ensures that user2 becomes the owner of all three NFTs and that the marketplace correctly processes multiple purchases.
Testing NFT delisting
The test_DelistingNFT
function verifies that user1 can delist their NFT from the marketplace.
User1 lists tokenId 0
for 1 Ether and calls delistNFT
.
The NFT is returned to user1, and the test confirms that ownership has reverted back to user1.
Run test
forge test
Deployment and verification on Linea
Add the required variables to the .env file, ensuring it is located at the root level of the project:
LINEA_RPC_URL=https://linea-mainnet.infura.io/v3/
PRIVATE_KEY=*************************************************
Add the details below to foundry.toml
[etherscan]
linea = { key = "${LINEA_API_KEY}", url = "https://api-sepolia.lineascan.build/api" }
Create a Makefile and add the details below:
deploy:
forge create src/Market.sol:NFTMarketplace --rpc-url $(LINEA_RPC_URL) --private-key $(PRIVATE_KEY)
verify:; forge verify-contract --rpc-url $(LINEA_RPC_URL) --chain linea <contract address> src/Market.sol:NFTMarketplace
Deployment
make deploy
Verification
make verify
Conclusion
Building an NFT marketplace on Linea provides developers with hands-on experience in smart contract development, Solidity programming, and web3 interactions. In this guide, we covered the core functionalities of a decentralized marketplace, including listing, buying, batch purchasing, and delisting NFTs, while ensuring secure and efficient transactions.
We also walked through the implementation and testing of the smart contract using Foundry, as well as the deployment and verification process on Linea. By following this guide, you now have the foundational knowledge to build, test, and deploy your own NFT marketplace.
For more hands-on tutorials and deep dives into blockchain development, check out the MetaMask Developer YouTube channel, and stay updated with our latest insights by following us on Twitter.