The rise of NFTs (Non-Fungible Tokens) created a new paradigm in the digital economy, revolutionizing how assets such as art, music, and collectibles are bought and sold. Building an NFT marketplace is foundational for developers looking to facilitate these transactions. Linea—a zkEVM rollup by Consensys—offers a robust and scalable environment to bring these projects to life.
This guide will explore how to develop and deploy a fully functional NFT marketplace on Linea. By the end of this guide, you’ll know how to create an innovative NFT marketplace and leverage Linea’s powerful blockchain solutions to cater to the growing demand for NFT trading.
Listing NFTs for Sale - Users can list their NFTs with a specified price, which gets stored securely in the contract.
Buying NFTs - Buyers can purchase listed NFTs by sending the required Ether, ensuring proper value exchange.
Batch Buying - Allows multiple NFTs to be purchased in a single transaction, optimizing for gas efficiency.
Delisting NFTs - NFT owners can delist their tokens and return them to their wallets.
Run the command below to initiate a foundry project. We will be using the Foundry framework to build the smart contract:
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
Create a market.sol file in the src folder and add the code below to the file:
// SPDX-License-Identifier: MIT
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;
}
// NFT Contract => Token ID => Listing
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");
_;
}
/**
* @dev List an NFT for sale.
* @param nftContract The address of the NFT contract.
* @param tokenId The ID of the token to list.
* @param price The price of the NFT in wei.
*/
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);
}
/**
* @dev Buy a listed NFT.
* @param nftContract The address of the NFT contract.
* @param tokenId The ID of the token to buy.
*/
function buyNFT(address nftContract, uint256 tokenId)
public
payable
isListed(nftContract, tokenId)
{
Listing memory listing = listings[nftContract][tokenId];
require(msg.value >= listing.price, "Insufficient funds");
// Remove listing
delete listings[nftContract][tokenId];
// Transfer payment to the seller
(bool sent, ) = listing.seller.call{value: listing.price}("");
require(sent, "Failed to send Ether");
// Transfer NFT to the buyer
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]);
}
}
/**
* @dev Delist an NFT from the marketplace.
* @param nftContract The address of the NFT contract.
* @param tokenId The ID of the token to delist.
*/
function delistNFT(address nftContract, uint256 tokenId)
external
isListed(nftContract, tokenId)
isSeller(nftContract, tokenId, msg.sender)
{
Listing memory listing = listings[nftContract][tokenId];
// Remove listing
delete listings[nftContract][tokenId];
// Return NFT to the seller
IERC721(nftContract).transferFrom(address(this), listing.seller, tokenId);
emit Delisted(nftContract, tokenId, listing.seller);
}
}
Listing Struct
This is foundational for implementing features like listing, buying, and delisting in a decentralized NFT marketplace. It ensures a straightforward and efficient way to manage the listing. It enables the marketplace to record each item’s seller and price, which are fundamental for marketplace operations.
struct Listing {
address seller; // NFT seller
uint256 price; // NFT price
}
Listing Mapping
This line of code defines a nested mapping in Solidity. It is used to organize and store listings for the NFT marketplace. The outer mapping
uses the address of an NFT contract as the key. This allows the smart contract to categorize listings by their associated NFT contract addresses, ensuring multiple NFT collections can be handled.
The inner mapping
uses the token ID (a unique identifier for each NFT) as the key. This allows the marketplace to efficiently handle listings for multiple tokens under a single NFT contract.
// NFT Contract => Token ID => Listing
mapping(address => mapping(uint256 => Listing)) public listings;
NFT Listing
The listNFT
function allows users to list their nft for sale on the nft marketplace. This function takes in three parameters, nftcontract, tokenId and price, the nftcontract is the address of the nft collection from which the user wants to list, the tokenId is the uinque id of the user’s nft in the collection and the price is the amount in which the user wants to sell the nft.
The function validates that the user owns the nft and ensures that the user enters a price greater than zero. After all validations, the nft is transferred from the seller to the nft marketplace, the listing 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 enables 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 validates that the buyer has sent sufficient Ether to cover the NFT's price. Once the validation is successful, the listing entry for the NFT is removed from the listings
mapping. The payment is then securely transferred to the seller, and the NFT is transferred from the marketplace contract to the buyer's address, completing 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");
// Remove listing
delete listings[nftContract][tokenId];
// Transfer payment to the seller
(bool sent, ) = listing.seller.call{value: listing.price}("");
require(sent, "Failed to send Ether");
// Transfer NFT to the buyer
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 for all the NFTs by summing the prices of the individual NFTs listed in the tokenIds
array. The function then checks if the buyer has sent sufficient funds to cover the total price.
Once the funds are validated, the function iterates through the tokenIds
array, calling the buyNFT
function for each NFT in the list. This ensures that each NFT is purchased individually, with payment being sent to the seller and the NFTs transferred to the buyer, similar to the process in the buyNFT
function described above.
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 seller of the NFT. After the validation, it deletes the listing from the marketplace, effectively removing it from the sale.
The function then transfers the NFT back to the seller, ensuring that the seller regains possession of their asset. The event Delisted
is emitted to notify that the NFT has been delisted successfully.
function delistNFT(address nftContract, uint256 tokenId)
external
isListed(nftContract, tokenId)
isSeller(nftContract, tokenId, msg.sender)
{
Listing memory listing = listings[nftContract][tokenId];
// Remove listing
delete listings[nftContract][tokenId];
// Return NFT to the seller
IERC721(nftContract).transferFrom(address(this), listing.seller, tokenId);
emit Delisted(nftContract, tokenId, listing.seller);
}
Key Events
The contract emits events for critical actions:
Create a NFT.sol file in the src folder and add the code below to the file.
// SPDX-License-Identifier: MIT
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;
}
/// @dev Function to mint a new NFT
function mint(address to) external onlyOwner {
uint256 tokenId = _tokenIdCounter;
_tokenIdCounter += 1;
_safeMint(to, tokenId);
emit Minted(to, tokenId);
}
/// @dev Override base token URI for metadata
function _baseURI() internal view override returns (string memory) {
return _baseTokenURI;
}
/// @dev Function to update the base token URI
function setBaseTokenURI(string memory newBaseTokenURI) external onlyOwner {
_baseTokenURI = newBaseTokenURI;
}
/// @dev Function to retrieve the current token ID count
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:
// SPDX-License-Identifier: UNLICENSED
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 NTF marketplace contract operates as intended across its core functionalities. Here’s a breakdown of the tests:
Setting Up the Test Environment
The setUp
function initializes the environment by deploying both NFTMarketplace
and NFT
contracts. It mints three NFTs and assigns them to user1
which will be used for testing. A mock NFT contract (NFT
) is deployed with the mint
function to generate NFTs. The marketplace contract (NFTMarketplace
) is instantiated user1
owns all the minted NFTs, making it possible to test listing and delisting functionalities.
Test Listing
The test_Listing
function ensures that user1
can list an NFT for sale on the marketplace, user1
approves the marketplace to transfer NFT tokenID0. The NFT is listed for one ether. The test ensures the listNFT
the function works correctly, and the NFT is transferred to the marketplace.
Test buying a Single NFT
The test_BuyingNFT
function simulates the purchase of a listed NFT by user2
, user1
lists NFT tokenID0 for one ether, user2
is funded with ten ether, and calls the buyNFT
function, sending one ether to purchase the NFT. After the purchase, the owner of NFT tokenID0 is verified to be user2
.
Test Buying Multiple NFTs
The test_BuyingMultipleNFT
function tests the ability to buy multiple NFTs in a single transaction, user1
lists three NFTs with tokenIds (0
, 1
, 2
) for one ether each.
user2
calls the buyMultipleNFT
function, passing the token IDs and sending three ether.
The test ensures that user2
it becomes the owner of all three NFTs and that the marketplace handles multiple purchases correctly.
Test Delisting an NFT
The test_DelistingNFT
the function verifies that user1
they can delist their NFT from the marketplace. user1
lists NFT tokenID0 for one ether and calls the delistNFT
function. The NFT is returned to user1
, and ownership of the NFT is confirmed to have reverted to user1
.
How to run Tests
forge test
Add the necessary variables to .env. The .env file should be at the root level:
LINEA_RPC_URL=https://linea-sepolia.blockpi.network/v1/rpc/public
PRIVATE_KEY=*************************************************
LINEA_API_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
Building an NFT marketplace is an exciting and impactful way to engage with the growing world of decentralized applications and Web3. By leveraging smart contract platforms like Linea and modern development tools like Foundry and Solidity, you can create a robust platform where users can trade digital assets securely and transparently. This guide provided a comprehensive overview, including the core features of listing, buying, and delisting NFTs.