In this article we will discuss how you can build your own NFT marketplace from scratch (duh…). However, this won’t be just that. We will be going a step further. We will not use big daddy cool – ETH for transactions. But we will use our own ERC20 token for it. There would be 3 separate contracts –
- A generic ERC20 contract.
- A generic ERC721 contract.
- A not-so-generic NFT Marketplace contract.
If you are a beginner in the field (and it’s a wonderful time to be so), then I would say just make sure to learn the fundamentals of solidity before jumping in. That’s all. You can be using everyone’s favorite grand old IDE – REMIX for or if you want then go ahead with Truffle, Hardhat, Brownie or Foundry.
The code files for this tutorial will be shared through the GitHub repo mentioned at the end. You might notice that it has a lot of files and folders. That’s cuz it’s a Hardhat project. The main files you need to be concerned with in this tutorial are inside the contracts
directory. In a separate article, I will talk about Hardhat and things like how to deploy and test your contracts in Hardhat. So, let’s get the show on the road!
Quick note:
- I will be using OpenZeppelin for this tutorial. If you do not know what that is (wow you are really a beginner then) then just know this - it's an easy and smart way of writing smart contracts in Ethereum by using already audited code.
- I am using code snippets, for full code please visit the GitHub repo mentioned at the end.
A Generic ERC20 Contract
contract TestToken is ERC20, Ownable {
constructor() ERC20("TestToken", "TT") {}
function mint(address to, uint256 amount) public onlyOwner {
_mint(to, amount);
}
}
So, for the generic ERC20 contract, we will be inheriting from OpenZeppelin’s ERC 20 contract and will also be using the Ownable
contract while at it. The code for it is pretty simple. The TestToken
contract is inheriting from ERC20
and Ownable
.
ERC20 contract helps us get the functionalities of a token that should follow ERC 20 convention without much huff n’ puff. All the functions that might be needed are already present now for our token without even writing a single custom method. Frankly, we could stop here and technically that would be it.
The Ownable
contract provides us with one important functionality – being able to “own” our contract. Now, you might be thinking here that “Hey! Wait a Minute. We are the ones making and deploying it, how come we don’t own it!?”. And as a beginner you would be correct in thinking that. But the thing is anyone can run any function on a contract that’s deployed.
Essentially, anyone can just come and mint 1 million of these tokens for themselves. Ownable provides us with a measure to put a restriction on certain functionalities. The address deploying the smart contract becomes the natural owner and the methods which have the onlyOwner
modifier will only run from this address. If we were giving a web dev analogy, this would essentially be the “admin” who can decide what goes on the site.
There is another contract which is sort of a generalization of this contract in OpenZeppelin – AccessControl
. It provides a finer RBAC in smart contracts. But we won’t be discussing it because that’s out of the scope of this article.
As you can see in the code, we are initializing the contract with a name and symbol being passed to the ERC20 constructor. That’s the name and symbol of our token. Feel free to insert your own.
The only other method is mint
. The method has a modifier onlyOwner
which we talked about previously. This means that the deploying address can only mint these ERC20 tokens. The deploying address can mint these to itself or to any other address the owner wishes.
That’s it for the generic ERC20 token contract.
A Generic ERC721 Contract
contract TestNft is ERC721, ERC721URIStorage, ERC721Burnable, Ownable {
using Counters for Counters.Counter;
Counters.Counter private _tokenIdCounter;
constructor() ERC721("WildNFTs", "WF") {}
...
}
Next up is the generic ERC721 contract. Now, you might think that this being a “NFT” marketplace article the “NFT” contract would be the MC (main character for anime weebs). But it’s not. Frankly, you can have any NFT contract and you can modify it to your whims. Won’t change a thing.
Here in this contract, we are inheriting from ERC721
, ERC721URIStorage
and Ownable
. The last contract has been explained in the previous section. The other two are nothing that special. Just like ERC20, ERC721 convention also demands that tokens conform to certain norms. This basically means there are certain function must be implemented in the token contract. They may not be used that often, but still they need to be there for the token to be called “ERC721”. Tokens arising from this contract are called “NFT” in case it’s still not clear.
ERC721URIStorage
is being inherited from because we want to associate each NFT with a specific URL. You may have heard about chimp, crocks and kitties in this context. All of them have this image attached to them, right? Well, it turns out you can store the image wherever you want. You can store them in your Google Drive, in AWS S3 bucket, GCP Cloud Storage Bucket or IPFS (preferably). It doesn’t really matter. What matters is that when you use an image from your collection, you provide the link to it when minting the NFT out of it. That URL gets recorded in the transaction and is stored in the contract. That’s what ERC721URIStorage
does – gives us the functionality to record the URL of that image with an NFT.
contract TestNft is ERC721, ERC721URIStorage, ERC721Burnable, Ownable {
...
function safeMint(address to, string memory uri) public onlyOwner {
uint256 tokenId = _tokenIdCounter.current();
_tokenIdCounter.increment();
_safeMint(to, tokenId);
_setTokenURI(tokenId, uri);
}
...
}
To mint a new NFT, you use the safeMint
function (passing in the address who would be the owner of that NFT and URL).
contract TestNft is ERC721, ERC721URIStorage, ERC721Burnable, Ownable {
...
function tokenURI(uint256 tokenId)
public
view
override(ERC721, ERC721URIStorage)
returns (string memory)
{
return super.tokenURI(tokenId);
}
...
}
Lastly, to view a NFT’s recorded URl, you use the tokenURI
function. It’s as simple as that. We are using a counter here to keep track of the token IDs. You can replace that with a uint256
variable in case you want the NFT token IDs to start from a specific number.
A Not-so-Generic NFT Marketplace Contract
And now the big boss – the NFT marketplace contract. Normally, many projects don’t want 3 separate contracts. They might not even want to use a custom Token and instead go with ETH. As for the marketplace, they might merge the NFT contract with the marketplace contract and that’s that. In fact, it would even solve a problem we will discuss in this section. But where’s the fun in that?
Doing things this way leaves room for improvement. For example, you might want to extend the marketplace to more than one NFT project and ERC20 token (and there are many current projects doing that), your project might need you to use more than one ERC20 token along with ETH for transacting NFTS (something we will explore in a future article) or you might even want to include an Oracle service for whatnot – possibilities are plenty.
However, in this article, we will be using a single NFT contract with a single custom ERC20 token.
contract TestMarketplace is Ownable, ReentrancyGuard, IERC721Receiver {
using Counters for Counters.Counter;
Counters.Counter private _itemIds;
Counters.Counter private _itemsSold;
uint256 private _amountCollected;
address public nftContract;
address public acceptedTokenAddress;
uint256 public listingPrice = 0.1 ether;
struct MarketItem {
uint itemId;
uint256 tokenId;
address seller;
address owner;
uint256 price;
bool isSold;
bool isUpForSale;
bool exists;
}
mapping(uint256 => MarketItem) public idToMarketItem;
event MarketItemCreated (
uint indexed itemId,
uint256 indexed tokenId,
address seller,
address owner,
uint256 price
);
event MarketItemUpForSale (
uint indexed itemId,
uint256 indexed tokenId,
address seller,
address owner,
uint256 price
);
...
}
In the lines following up to the constructor of the contract we doing a lot of things that need to be addressed:
- We are defining variables to hold addresses of the ERC20 token we want to use, the NFT contract we want to use and the Listing Price. The first two variables are easy to understand, right? Listing Price might be something new. The question you need to ask here is why might I allow anyone to list anything on my marketplace for free. What’s in it for you? Listing Price is the minimum price others need to pay (in the ERC20 token specified) for using your marketplace. It’s a one-time fee essentially.
- We are declaring an
_amountCollected
variable. It is used to count how much amount of ERC20 token the contract has accrued from all the listing. This can then be withdrawn by the contract owner to their own account as payment for providing the marketplace service. - A
MarketItem
struct. This is used for holding information about the listed NFTs. Things like theprice
, theowner
, theseller
, thetokenID
of the NFT in the NFT contract, the ID of the NFT in this marketplace (itemID
). You might be thinking what might be the difference between owner and seller here. It’s explained in the later sections. - We are also declaring a mapping which would be used to basically store all the market items.
- There are two events we are declaring as well – one for when any new Item gets listed and another for when any item is put up for sale. The idea here is that even though an item is listed, it might not be up for sale. The owner (who might have bought from this marketplace) might want to hold onto it.
contract TestMarketplace is Ownable, ReentrancyGuard, IERC721Receiver {
...
constructor(address _nftContract, address _acceptedTokenAddress) {
nftContract = _nftContract;
acceptedTokenAddress = _acceptedTokenAddress;
}
...
}
In the constructor we are passing in the ERC20 token we want to accept for transacting with NFTs in this marketplace and the NFT contract which those NFT must belong to.
contract TestMarketplace is Ownable, ReentrancyGuard, IERC721Receiver {
...
function onERC721Received(
address operator,
address from,
uint256 tokenId,
bytes calldata data
) external override returns (bytes4){
return this.onERC721Received.selector;
}
...
}
There is a function onERC721Received
which goes undiscussed in many marketplace articles and tutorials. This is an important function and implementing it is best practice because it allows safe transfer of NFTs. This is also the problem we were talking about in the previous part. When you merge your NFT contract with Marketplace contract, this problem is avoided because ERC721 inheritance provides this function (bet you didn’t know that?). In our case, for custom marketplace, we need to implement it ourselves. It’s a simple function that does nothing but return its selector to the NFT contract when transfer is initiated from the NFT contract to this marketplace contract.
The onERC721Received
function is a safe guard because NFT contracts inheriting ERC721 do not allow transfer to contract address because that contract might not have the functionality to transfer to other addresses the NFT it’s receiving. When an NFT ownership transfer is initiated, the NFT contract invokes this function (notice it’s an external function meaning it cannot be invoked by the marketplace contract but only by outside agents). This allows us to use safeTransferFrom
function which provides additional safeguards for NFT transfers. Thus, onERC721Received is a frequently looked over important function that needs to be present in NFT marketplace.
After this we have the 3 functions which form the crux of the contract – addItemToMarket
, createSale
and buyItem
. They are used for adding an NFT to marketplace, creating a sale of that NFT and allowing users to buy that NFT respectively. Depending upon your experience with these sorts of things, you might be getting a feeling that something’s missing. You are right. I have not defined any function to delist any item from marketplace. I am leaving that up to you. I will of course help out by explaining the logic for that in the end. But before that let’s discuss the three main functions.
addItemToMarket
contract TestMarketplace is Ownable, ReentrancyGuard, IERC721Receiver {
...
function addItemToMarket(
uint256 tokenId,
uint256 price
) public nonReentrant {
require(price > 0, "Price must be at least 1 wei");
require(price >= listingPrice, "Price should be at least same as listing price");
_itemIds.increment();
uint256 itemId = _itemIds.current();
idToMarketItem[itemId] = MarketItem(
itemId,
tokenId,
msg.sender,
address(0),
price,
false,
false,
true
);
IERC20(acceptedTokenAddress).transferFrom(msg.sender, address(this), listingPrice);
IERC721(nftContract).safeTransferFrom(msg.sender, address(this), tokenId);
_amountCollected += listingPrice;
emit MarketItemCreated(
itemId,
tokenId,
msg.sender,
address(0),
price
);
}
...
}
This function is used to – and you guessed it right – add NFTs from our NFT contract to this marketplace. The invoker passes in the token’s ID of the NFT in our NFT smart contract along with the price he/she/it (you never know when a robot might get offended) wants.
Here we put in 1 check – price must be above or equal to the listing price. The listing price is deducted from the price in this function and the remaining price is the price the NFT is listed at.
We increment the _itemIds
counter to get this contract’s item ID for this NFT and use that to create a market item via the struct we had declared previously. The NFT listed in the marketplace, thus, has its token ID from it’s native contract, it’s item ID for marketplace contract recorded along with the msg.sender
set as owner and this contract set as seller. There’s 3 boolean values also set which state the item is not sold ever, is not up for sale and it exists respectively. The last one is for checks in later functions.
We facilitate the transfers after defining the market item in the contract. If the invoker of the function is not the owner, the execution would end and the transaction would err out/revert. As a beginner, you might think “but the item is already created in the marketplace!” but here’s the thing, function executions are atomic transactions – they either execute fully or they don’t.
Once the transfers are done, we record the amount we have collected via the listing fee we are charging and then emit the event that an item has been created. This concludes the flow of listing an NFT in the marketplace.
createSale
contract TestMarketplace is Ownable, ReentrancyGuard, IERC721Receiver {
...
function createSale(
uint256 itemId
) public nonReentrant {
MarketItem memory item = idToMarketItem[itemId];
require(item.owner == msg.sender, "Only Item owner can create sale.");
require(item.exists == true, "Item does not exist.");
idToMarketItem[itemId].isUpForSale = true;
emit MarketItemUpForSale (
itemId,
item.tokenId,
msg.sender,
item.seller,
item.price
);
}
...
}
This function is used for creating a sale out of already listed NFTs. The last bool in the market item struct’s definition comes handy in here. If the item is not listed/created, then this bool would default to a value of false
. Also, we check that the invoker is the owner of the NFT (no one would want their NFTs to be sold without their consent, right?).
We provide the owner of the NFT with a chance to change the price of the NFT here in case he/she/it (again, offended bots) might want to sell it at a higher price. We conclude the function by emitting an event which says that the NFT is up for sale.
buyItem
contract TestMarketplace is Ownable, ReentrancyGuard, IERC721Receiver {
...
function buyItem(
uint256 itemId,
uint256 itemPrice
) public nonReentrant {
uint price = idToMarketItem[itemId].price;
uint tokenId = idToMarketItem[itemId].tokenId;
bool isUpForSale = idToMarketItem[itemId].isUpForSale;
require(itemPrice >= price, "Asking Price not satisfied!");
require(isUpForSale == true, "NFT not for sale.");
address prevSeller = idToMarketItem[itemId].seller;
idToMarketItem[itemId].owner = msg.sender;
idToMarketItem[itemId].seller = msg.sender;
idToMarketItem[itemId].isSold = true;
idToMarketItem[itemId].isUpForSale = false;
IERC721(nftContract).transferFrom(prevSeller, msg.sender, tokenId);
IERC20(acceptedTokenAddress).transferFrom(msg.sender, prevSeller, itemPrice);
_itemsSold.increment();
}
...
}
In this function, the invoker enters the itemID
of the NFT in the marketplace along with the price (itemPrice
) at which he/she might want to buy it. Now normally, no one would like to pay more than the sale price of the NFT but we are just being optimistic here by putting the check that itemPrice
argument must be greater than or equal to the price of the NFT (who know perhaps the invoker might be feeling generous). There’s also the important check if the item is actually up for sale or not.
We store the address of the previous owner of the NFT and then change both the Owner and the Seller to the invoker’s address. This means that when the NFT is being bought first time from the marketplace, the seller would be changing from contract address to the invoker’s address. From there on out, the owner and seller would be one and all. It’s just one of the logics I implemented. Opinions may vary and you might have a different logic in this case.
We update the isSold
flag to true indicating that the NFT has changed hands once before on this marketplace and then change the isUpForSale
flag to false indicating that the new owner needs to manually create a new sale for this NFT now.
Then we facilitate the transfer of ERC20 Token from buyer to owner’s address and the NFT from the owner’s to buyer’s address. Last but not the least, we increment the counter we have for keeping track of total sold items.
If you have the same logic for assigning token IDs to NFTs as this smart contract has, then the difference between token ID counter and total sold item counter would give you the total unsold items.
Other nuances
contract TestMarketplace is Ownable, ReentrancyGuard, IERC721Receiver {
...
function getMarketItemById(uint256 marketItemId) public view returns (MarketItem memory) {
MarketItem memory item = idToMarketItem[marketItemId];
return item;
}
function getUnsoldItems() public view returns (MarketItem[] memory) {
uint itemCount = _itemIds.current();
uint unsoldItemCount = _itemIds.current() - _itemsSold.current();
uint currentIndex = 0;
MarketItem[] memory items = new MarketItem[](unsoldItemCount);
for (uint i = 0; i < itemCount; i++) {
if (!idToMarketItem[i + 1].isSold) {
uint currentId = i + 1;
MarketItem memory currentItem = idToMarketItem[currentId];
items[currentIndex] = currentItem;
currentIndex += 1;
}
}
return items;
}
...
}
Now that the main things are discussed, we need to discuss the two other utility functions in the contract. getMarketItemById
function gives the details of the item if the invoker provides the item ID while the getUnsoldItems
provides the unsold items in the marketplace.
These are great examples of how to waste gas because of 2 reasons: -
- The mapping of
idToMarketItem
is public so everything is visible from there. - Most likely users would be interacting with an interface infront of this contract so these things can be calculated off-chain without much fuss.
But hey, if your client wants to be wasteful, who are you to object? XD
The other thing you might notice is the
nonReentrant
modifier in the function prototype of some functions. This is what prevents a “Reentrancy Attack”. If you are new and unaware of it, just know right now that it’s a pretty big deal and make sure to search up on it later. It’s provided by inheritance from theReentrancyGaurd
smart contract provided by OpenZeppelin. You might also be thinking why are the functions in which we are transacting in ERC20 tokens not declared aspayable
? That’s because it is not in Ether that we are transacting. Transacting in Eth and ERC20 tokens are two very different things. I believe at the time of writing there an EIP in the pipeline which might change that in the future. For a complete list of EIPs visit https://github.com/ethereum/EIPs and you will find all of them inside the EIPS directory.
Before we close off, I guess there’s 2 more things left to discuss –
- Withdrawing funds from the contract – All the ERC20 Tokens accrued need to be withdrawn at some point. Otherwise, what’s the point of charging it if you cannot use it, right? For that reason, you need to implement a withdraw function which will withdraw the
_amountCollected
amount of those ERC20 token from contract to the owner’s address (make sure to implement it so that only owner can invoke it). - Delisting NFTs from marketplace – It’s as easily done as deleting the key/item ID of the NFT from the marketplace. Since we are using a mapping that is one option available to us. Other thing you might not want to do but is possible is using another Boolean flag for indicating if the item is delisted or not. You can also take it many steps further and make a marketplace which can accommodate multiple NFT contracts and tokens. Wrapping things up The code for this tutorial is available on my GitHub here In this article we saw how easily an ERC 20 token can be created for transacting of ERC 721 type NFT on a custom marketplace contract. Hopefully, not everything went over your head. There are a few topics I would love to explore in the future articles like testing in hardhat (using these contract), taking this marketplace a step further and use Uniswap to add feature for buying through multiple tokens among other things. I hope you liked reading through it all and if you have a suggestion, feel free to get in contact with me over Twitter or over my mail (abhik@abhikbanerjee.com).
Until then, keep believing in Web3 and WAGMI !!
Top comments (1)
blog.sagipl.com/metaverse-nft-mark...