DEV Community

fangjun
fangjun

Posted on • Updated on

Web3 Tutorial: Build an NFT marketplace DApp like Opensea

Building an NFT marketplace DApp like Opensea with Solidity and Web3-React is a good step in your web3 journey. In my previous tutorials, you may already learn:

  • how to write a smart contract and run it on blockchain local testnet using hardhat.

  • how to build a full-stack DApp which means deal with all three parts of a DApp: smart contract, web app and wallet.

  • how to unit test, deploy to public testnet, verify smart contract on block explorer etherscan.

Now you can begin to write a smart contract with complete functionality: a marketplace for digital items. NFTs of a collection are the digital items traded here.

Table of content


Nader Dabit write two version of How to Build a Full Stack NFT Marketplace - V2 (2022). Inspired by his idea and based on his smart contract code base, I develop and write this tutorial for you.

You may read my previous tutorials and practice after them. If not, I suggest that you read the following two before you start as I will not explain some techniques already explained there.

Let's begin building.


Task 1

Task 1: What we build and project setup

Task 1.1: What we build - three parts

  • An NFT smart contract and a simple web page to display an NFT item. We will use on-chain SVG as the image of an NFT item. We need a sample NFT collection to work with in the marketplace contract and storefront.

  • An NFT marketplace smart contract user can list an NFT and buy an NFT. Seller can delist his own NFT from the market. It also provides query functions for webapp to query data of the market. We will try to cover this smart contract with unit test as much as possible.

  • An NFT marketplace storefront using React/Web3-React/SWR. (To make it simple, we only build the necessary components of the storefront in a one-page webapp. For example, we don't provide functions for sellers to list an NFT to the market in the webapp.

The main part of this project is the marketplace smart contract (NFTMarketplace) with data storage, core functions and query functions.

Core functions:

  function createMarketItem(address nftContract,uint256 tokenId,uint256 price) payable 
  function deleteMarketItem(uint256 itemId) public
  function createMarketSale(address nftContract,uint256 id) public payable
Enter fullscreen mode Exit fullscreen mode

Query functions:

  function fetchActiveItems() public view returns (MarketItem[] memory) 
  function fetchMyPurchasedItems() public view returns (MarketItem[] memory)
  function fetchMyCreatedItems() public view returns (MarketItem[] memory) 
Enter fullscreen mode Exit fullscreen mode

A seller can use the smart contract to:

  • approve an NFT to market contract
  • create a market item with listing fee
  • (waiting for a buyer to buy the NFT)
  • receive the price value

When a buyer buys in the market, the market contract facilitates the purchase process:

  • buyer buys with paying the price value
  • market contract compeletes the purchase process:
    • transfer the price value to seller
    • transfer the NFT from seller to buyer
    • transfer the listing fee to the market owner
    • change market item state from Created to Release

GitHub repos of this tutorial:

Although I learned a lot from Dabit's NFT marketplace tutorial, what we will build has 3 major differences:

  • Dabit's NFT is a traditional one which stores images on IPFS while our NFT stores SVG image on-chain. We take this choice to make our tutorial simple as we need not to deal with setup a server to provide NFT tokenURI (restful json api) and need not to deal with image storage on server or IPFS.

  • In the first version of Dabit's tutorial, he separated the NFT ERC721 token smart contract and the marketplace smart contract. In the second version, he chooses to build an NFT ERC721 with maketplace functionality in one smart contract. We choose to separate them here as we would like to build a general marketplace.

  • In Dabit's tutorial, when seller lists an NFT item to marketplace, he transfers the NFT item to market contract and waits for it to be sold. As an NFT user, I don't like this pattern. I would like to approve only the item to the marketplace. Before it is sold, the item is still in my address. (I also would like not to setApprovalForAll() to approve all the NFT items in this collection in my address to the market contract. We choose to approve in a one-by-one style.)

Task 1.2: Directory and project setup

STEP 1: Make directories

We will separate our project into two sub-directories, chain for hardhat project, and webapp for React/Next.js project.

--nftmarket
  --chain
  --webapp
Enter fullscreen mode Exit fullscreen mode

STEP 2: Hardhat project

In chain sub-directory, instal hardhat development environment and @openzeppelin/contracts solidity library. Then we init an empty hardhat project.

yarn init -y
yarn add hardhat
yarn add @openzeppelin/contracts
yarn hardhat
Enter fullscreen mode Exit fullscreen mode

Alternatively, you can download the hardhart chain starter project from github repo. In your nftmarket directory:

git clone git@github.com:fjun99/chain-tutorial-hardhat-starter.git chain
cd chain
yarn install
Enter fullscreen mode Exit fullscreen mode

STEP 3: React/Next.js webapp project

You can download an empty webapp scaffold:

git clone https://github.com/fjun99/webapp-tutorial-scaffold.git webapp
cd webapp
yarn install
yarn dev
Enter fullscreen mode Exit fullscreen mode

You can also download the webapp codebase of this tutorial:

git clone git@github.com:fjun99/web3app-tutrial-using-web3react.git webapp
cd webapp
git checkout nftmarket
yarn install
Enter fullscreen mode Exit fullscreen mode

task 2

Task 2: NFT collection smart contract

Task 2.1: write an NFT smart contract

We write an NFT ERC721 smart contract inheriting OpenZeppelin's ERC721 implemention. We add three functionality here:

  • auto increment tokenId starting from 1
  • mintTo(address _to) everyone can mint
  • tokenURI() to implement token URI and on-chain SVG images
// contracts/BadgeToken.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

import "@openzeppelin/contracts/token/ERC721/ERC721.sol";
import "@openzeppelin/contracts/utils/Strings.sol";
import "@openzeppelin/contracts/utils/Base64.sol";

contract BadgeToken is ERC721 {
    uint256 private _currentTokenId = 0; //tokenId will start from 1

    constructor(
        string memory _name,
        string memory _symbol
    ) ERC721(_name, _symbol) {

    }

    /**
     * @dev Mints a token to an address with a tokenURI.
     * @param _to address of the future owner of the token
     */
    function mintTo(address _to) public {
        uint256 newTokenId = _getNextTokenId();
        _mint(_to, newTokenId);
        _incrementTokenId();
    }

    /**
     * @dev calculates the next token ID based on value of _currentTokenId
     * @return uint256 for the next token ID
     */
    function _getNextTokenId() private view returns (uint256) {
        return _currentTokenId+1;
    }

    /**
     * @dev increments the value of _currentTokenId
     */
    function _incrementTokenId() private {
        _currentTokenId++;
    }

    /**
     * @dev return tokenURI, image SVG data in it.
     */
    function tokenURI(uint256 tokenId) override public pure returns (string memory) {
        string[3] memory parts;

        parts[0] = "<svg xmlns='http://www.w3.org/2000/svg' preserveAspectRatio='xMinYMin meet' viewBox='0 0 350 350'><style>.base { fill: white; font-family: serif; font-size: 300px; }</style><rect width='100%' height='100%' fill='brown' /><text x='100' y='260' class='base'>";

        parts[1] = Strings.toString(tokenId);

        parts[2] = "</text></svg>";

        string memory json = Base64.encode(bytes(string(abi.encodePacked(
            "{\"name\":\"Badge #", 
            Strings.toString(tokenId), 
            "\",\"description\":\"Badge NFT with on-chain SVG image.\",",
            "\"image\": \"data:image/svg+xml;base64,", 
            // Base64.encode(bytes(output)), 
            Base64.encode(bytes(abi.encodePacked(parts[0], parts[1], parts[2]))),     
            "\"}"
            ))));

        return string(abi.encodePacked("data:application/json;base64,", json));
    }    
}

Enter fullscreen mode Exit fullscreen mode

We also add a deploy srcipt scripts/deploy_BadgeToken.ts with deploy it with name:BadgeToken and symbol:BADGE:

  const token = await BadgeToken.deploy('BadgeToken','BADGE')
Enter fullscreen mode Exit fullscreen mode

Task 2.2: Understand tokenURI()

Let's explain the ERC721 tokenURI() function implementation.

tokenURI() is a metadata function for ERC721 standard. OpenZeppelin docs :

tokenURI(uint256 tokenId) → string
Returns the Uniform Resource Identifier (URI) for tokenId token.

Usually tokenURI returns a URI. We can get the resulting URI for each token by concatenating the baseURI and the tokenId.

In our tokenURI(), we return URI as an object with base64 encoded:

First we construct the object. The svg image in the object is also base64 encoded.

{
"name":"Badge #1",
"description":"Badge NFT with on-chain SVG image."
"image":"data:image/svg+xml;base64,[svg base64 encoded]"
}
Enter fullscreen mode Exit fullscreen mode

Then we return the object base64 encoded.

data:application/json;base64,(object base64 encoded)
Enter fullscreen mode Exit fullscreen mode

Webapp can get URI by calling tokenURI(tokenId), and decode to get the name, description and SVG image.

The SVG image is adapted from LOOT project. It is very simple. It display the tokenId in the image.

<svg xmlns='http://www.w3.org/2000/svg' preserveAspectRatio='xMinYMin meet' viewBox='0 0 350 350'>
<style>.base { fill: white; font-family: serif; font-size: 300px; }</style>
<rect width='100%' height='100%' fill='brown' />
    <text x='100' y='260' class='base'>
    1
    </text>
</svg>
Enter fullscreen mode Exit fullscreen mode

Task 2.3: Unit test for ERC721 contract

Let's write an unit test script for this contract:

// test/BadgeToken.test.ts
import { expect } from "chai"
import { Signer } from "ethers"
import { ethers } from "hardhat"
import { BadgeToken } from  "../typechain"

const base64 = require( "base-64")

const _name='BadgeToken'
const _symbol='BADGE'

describe("BadgeToken", function () {
  let badge:BadgeToken
  let account0:Signer,account1:Signer

  beforeEach(async function () {
    [account0, account1] = await ethers.getSigners()
    const BadgeToken = await ethers.getContractFactory("BadgeToken")
    badge = await BadgeToken.deploy(_name,_symbol)
  })

  it("Should has the correct name and symbol ", async function () {
    expect(await badge.name()).to.equal(_name)
    expect(await badge.symbol()).to.equal(_symbol)
  })

  it("Should tokenId start from 1 and auto increment", async function () {
    const address1=await account1.getAddress()
    await badge.mintTo(address1)
    expect(await badge.ownerOf(1)).to.equal(address1)

    await badge.mintTo(address1)
    expect(await badge.ownerOf(2)).to.equal(address1)
    expect(await badge.balanceOf(address1)).to.equal(2)
  })

  it("Should mint a token with event", async function () {
    const address1=await account1.getAddress()
    await expect(badge.mintTo(address1))
      .to.emit(badge, 'Transfer')
      .withArgs(ethers.constants.AddressZero,address1, 1)
  })

  it("Should mint a token with desired tokenURI (log result for inspection)", async function () {
    const address1=await account1.getAddress()
    await badge.mintTo(address1)

    const tokenUri = await badge.tokenURI(1)
    // console.log("tokenURI:")
    // console.log(tokenUri)

    const tokenId = 1
    const data = base64.decode(tokenUri.slice(29))
    const itemInfo = JSON.parse(data)
    expect(itemInfo.name).to.be.equal('Badge #'+String(tokenId))
    expect(itemInfo.description).to.be.equal('Badge NFT with on-chain SVG image.')

    const svg = base64.decode(itemInfo.image.slice(26))
    const idInSVG = svg.slice(256,-13)
    expect(idInSVG).to.be.equal(String(tokenId))
    // console.log("SVG image:")
    // console.log(svg)
  })  

  it("Should mint 10 token with desired tokenURI", async function () {
    const address1=await account1.getAddress()

    for(let i=1;i<=10;i++){
      await badge.mintTo(address1)
      const tokenUri = await badge.tokenURI(i)

      const data = base64.decode(tokenUri.slice(29))
      const itemInfo = JSON.parse(data)
      expect(itemInfo.name).to.be.equal('Badge #'+String(i))
      expect(itemInfo.description).to.be.equal('Badge NFT with on-chain SVG image.')

      const svg = base64.decode(itemInfo.image.slice(26))
      const idInSVG = svg.slice(256,-13)
      expect(idInSVG).to.be.equal(String(i))
    }

    expect(await badge.balanceOf(address1)).to.equal(10)
  })  
})
Enter fullscreen mode Exit fullscreen mode

Run the unit test:

yarn hardhat test test/BadgeToken.test.ts
Enter fullscreen mode Exit fullscreen mode

Results:

  BadgeToken
    ✓ Should has the correct name and symbol
    ✓ Should tokenId start from 1 and auto increment
    ✓ Should mint a token with event
    ✓ Should mint a token with desired tokenURI (log result for inspection) (62ms)
    ✓ Should mint 10 token with desired tokenURI (346ms)
  5 passing (1s)
Enter fullscreen mode Exit fullscreen mode

We can also print the tokenURI we get in the unit test for inspection:

tokenURI:
data:application/json;base64,eyJuYW1lIjoiQmFkZ2UgIzEiLCJkZXNjcmlwdGlvbiI6IkJhZGdlIE5GVCB3aXRoIG9uLWNoYWluIFNWRyBpbWFnZS4iLCJpbWFnZSI6ICJkYXRhOmltYWdlL3N2Zyt4bWw7YmFzZTY0LFBITjJaeUI0Yld4dWN6MG5hSFIwY0RvdkwzZDNkeTUzTXk1dmNtY3ZNakF3TUM5emRtY25JSEJ5WlhObGNuWmxRWE53WldOMFVtRjBhVzg5SjNoTmFXNVpUV2x1SUcxbFpYUW5JSFpwWlhkQ2IzZzlKekFnTUNBek5UQWdNelV3Sno0OGMzUjViR1UrTG1KaGMyVWdleUJtYVd4c09pQjNhR2wwWlRzZ1ptOXVkQzFtWVcxcGJIazZJSE5sY21sbU95Qm1iMjUwTFhOcGVtVTZJRE13TUhCNE95QjlQQzl6ZEhsc1pUNDhjbVZqZENCM2FXUjBhRDBuTVRBd0pTY2dhR1ZwWjJoMFBTY3hNREFsSnlCbWFXeHNQU2RpY205M2JpY2dMejQ4ZEdWNGRDQjRQU2N4TURBbklIazlKekkyTUNjZ1kyeGhjM005SjJKaGMyVW5QakU4TDNSbGVIUStQQzl6ZG1jKyJ9
SVG image:
<svg xmlns='http://www.w3.org/2000/svg' preserveAspectRatio='xMinYMin meet' viewBox='0 0 350 350'><style>.base { fill: white; font-family: serif; font-size: 300px; }</style><rect width='100%' height='100%' fill='brown' /><text x='100' y='260' class='base'>1</text></svg>
Enter fullscreen mode Exit fullscreen mode

task 3

Task 3: A web page to display NFT item

Task 3.1: Setup webapp project using Web3-React & Chakra UI

We will use web3 connecting framework Web3-React to get our job done. The web app stack is:

  • React
  • Next.js
  • Chakra UI
  • Web3-React
  • ethers.js
  • SWR

The _app.tsx is:

// src/pages/_app.tsx
import { ChakraProvider } from '@chakra-ui/react'
import type { AppProps } from 'next/app'
import { Layout } from 'components/layout'
import { Web3ReactProvider } from '@web3-react/core'
import { Web3Provider } from '@ethersproject/providers'

function getLibrary(provider: any): Web3Provider {
  const library = new Web3Provider(provider)
  return library
}

function MyApp({ Component, pageProps }: AppProps) {
  return (
    <Web3ReactProvider getLibrary={getLibrary}>
      <ChakraProvider>
        <Layout>
        <Component {...pageProps} />
        </Layout>
      </ChakraProvider>
    </Web3ReactProvider>
  )
}

export default MyApp

Enter fullscreen mode Exit fullscreen mode

We will use the ConnectMetamask component in our previous tutorial: Tutorial: build DApp with Web3-React and SWRTutorial: build DApp with Web3-React and SWR.

Task 3.2: Write a component to display NFT time

In this component, we also use SWR as we do in the previous tutorial. The SWR fetcher is in utils/fetcher.tsx.

// components/CardERC721.tsx
import React, { useEffect,useState } from 'react';
import { useWeb3React } from '@web3-react/core'
import { Web3Provider } from '@ethersproject/providers'
import { Box, Text} from '@chakra-ui/react'
import useSWR from 'swr'
import { ERC721ABI as abi} from "abi/ERC721ABI"
import { BigNumber } from 'ethers'
import { fetcher } from 'utils/fetcher'
const base64 = require( "base-64")

interface Props {
    addressContract: string,
    tokenId:BigNumber
}

interface ItemInfo{
  name:string,
  description:string,
  svg:string
}

export default function CardERC721(props:Props){
  const addressContract = props.addressContract
  const {  account, active, library } = useWeb3React<Web3Provider>()

  const [itemInfo, setItemInfo] = useState<ItemInfo>()

  const { data: nftURI } = useSWR([addressContract, 'tokenURI', props.tokenId], {
    fetcher: fetcher(library, abi),
  })

useEffect( () => {
  if(!nftURI) return

  const data = base64.decode(nftURI.slice(29))
  const itemInfo = JSON.parse(data)
  const svg = base64.decode(itemInfo.image.slice(26))
  setItemInfo({
    "name":itemInfo.name,
    "description":itemInfo.description,
    "svg":svg})

},[nftURI])

return (
  <Box my={2} bg='gray.100' borderRadius='md' width={220} height={260} px={3} py={4}>
  {itemInfo
  ?<Box>
    <img src={`data:image/svg+xml;utf8,${itemInfo.svg}`} alt={itemInfo.name} width= '200px' />
    <Text fontSize='xl' px={2} py={2}>{itemInfo.name}</Text>
  </Box>
  :<Box />
  }
  </Box>
)
}

Enter fullscreen mode Exit fullscreen mode

Some explanations:

  • When connected to Metamask wallet, this component queries tokenURI(tokenId) to get name, description and svg image of the NFT item.

Let's write a page to display NFT item.

// src/pages/samplenft.tsx
import type { NextPage } from 'next'
import Head from 'next/head'
import { VStack, Heading } from "@chakra-ui/layout"
import ConnectMetamask from 'components/ConnectMetamask'
import CardERC721 from 'components/CardERC721'
import { BigNumber } from 'ethers'

const nftAddress = '0x5fbdb2315678afecb367f032d93f642f64180aa3'
const tokenId = BigNumber.from(1)
const SampleNFTPage: NextPage = () => {

  return (
    <>
      <Head>
        <title>My DAPP</title>
      </Head>

      <Heading as="h3"  my={4}>NFT Marketplace</Heading>

      <ConnectMetamask />

      <VStack>
          <CardERC721 addressContract={nftAddress} tokenId={tokenId} ></CardERC721>
      </VStack>
    </>
  )
}

export default SampleNFTPage

Enter fullscreen mode Exit fullscreen mode

Task 3.3: Run the webapp project

STEP 1: Run a stand-alone local testnet

In another terminal, run in chain/ directory:

yarn hardhat node
Enter fullscreen mode Exit fullscreen mode

STEP 2: Deploy BadgeToken (ERC721) to local testnet

yarn hardhat run scripts/deploy_BadgeToken.ts --network localhost
Enter fullscreen mode Exit fullscreen mode

Result:

Deploying BadgeToken ERC721 token...
BadgeToken deployed to: 0x5FbDB2315678afecb367f032d93F642f64180aa3
Enter fullscreen mode Exit fullscreen mode

STEP 3: Mint a BadgeToken (tokenId = 1) in hardhat console

Run hardhat console connect to local testenet

yarn hardhat console --network localhost
Enter fullscreen mode Exit fullscreen mode

In console:

nftaddress = '0x5FbDB2315678afecb367f032d93F642f64180aa3'
nft = await ethers.getContractAt("BadgeToken", nftaddress)

await nft.name()
//'BadgeToken'

await nft.mintTo('0xf39fd6e51aad88f6f4ce6ab8827279cfffb92266')
// tx response ...

await nft.tokenURI(1)
//'data:application/json;base64,eyJuYW1lIjoiQmFkZ2UgIzEiLCJk...'
Enter fullscreen mode Exit fullscreen mode

Now we have the NFT item. We will display it in the web page.

STEP 3: prepare your MetaMask

Make sure your MetaMask has the local testnet wich RPC URL http://localhost:8545 and chain id 31337.

STEP 4: run webapp

In webapp/, run:

yarn dev
Enter fullscreen mode Exit fullscreen mode

In chrome browser, goto page: http://localhost:3000/samplenft.

Connect MetaMask, the NFT item will be displayed on the page. (Please note that the image is lazy loading. Wait for loading to be completed.

nft item

We can see that the NFT "Badge #1" with tokenId 1 is displayed correctly.


task 4

Task 4: NFT marketplace smart contract

Task 4.1: Contract data structure

We adapted the Market.sol smart contract from Nader Dabit's tutorial (V1) to write our marketplace. Thanks. But you should note that we changed a lot in this contract.

We define a struct MarketItem:

  struct MarketItem {
    uint id;
    address nftContract;
    uint256 tokenId;
    address payable seller;
    address payable buyer;
    uint256 price;
    State state;
  }
Enter fullscreen mode Exit fullscreen mode

Every market item can be one of three states:

  enum State { Created, Release, Inactive }
Enter fullscreen mode Exit fullscreen mode

Please note that we can't rely on Created State. If seller transfers the NFT item to others or seller removes approval of the NFT item, the state is still be Created indicating others can buy in the market. Actually other can't buy it.

All items are stored in a mapping:

  mapping(uint256 => MarketItem) private marketItems;
Enter fullscreen mode Exit fullscreen mode

The market has an owner which is the contract deployer. Listing fee will be going to market owner when an NFT item is sold in the market.

In the future we may add functionalities to transfer the ownership to other addresses or multi-sig wallet. To make the tutorial simple, we skip these functions.

This market has a static listing fee:

  uint256 public listingFee = 0.025 ether;
  function getListingFee() public view returns (uint256) 
Enter fullscreen mode Exit fullscreen mode

Task 4.2: market methods

The marketplace have two categories of methods:

Core fucntions:

  function createMarketItem(address nftContract,uint256 tokenId,uint256 price) payable 
  function deleteMarketItem(uint256 itemId) public
  function createMarketSale(address nftContract,uint256 id) public payable
Enter fullscreen mode Exit fullscreen mode

Query functions:

  function fetchActiveItems() public view returns (MarketItem[] memory) 
  function fetchMyPurchasedItems() public view returns (MarketItem[] memory)
  function fetchMyCreatedItems() public view returns (MarketItem[] memory) 
Enter fullscreen mode Exit fullscreen mode

The full smart contract goes as follows:

// contracts/NFTMarketplace.sol
// SPDX-License-Identifier: MIT OR Apache-2.0
// 
// adapt and edit from (Nader Dabit): 
//    https://github.com/dabit3/polygon-ethereum-nextjs-marketplace/blob/main/contracts/Market.sol

pragma solidity ^0.8.3;

import "@openzeppelin/contracts/token/ERC721/ERC721.sol";
import "@openzeppelin/contracts/utils/Counters.sol";
import "@openzeppelin/contracts/security/ReentrancyGuard.sol";
import "@openzeppelin/contracts/utils/Address.sol";

import "hardhat/console.sol";

contract NFTMarketplace is ReentrancyGuard {
  using Counters for Counters.Counter;
  Counters.Counter private _itemCounter;//start from 1
  Counters.Counter private _itemSoldCounter;

  address payable public marketowner;
  uint256 public listingFee = 0.025 ether;

  enum State { Created, Release, Inactive }

  struct MarketItem {
    uint id;
    address nftContract;
    uint256 tokenId;
    address payable seller;
    address payable buyer;
    uint256 price;
    State state;
  }

  mapping(uint256 => MarketItem) private marketItems;

  event MarketItemCreated (
    uint indexed id,
    address indexed nftContract,
    uint256 indexed tokenId,
    address seller,
    address buyer,
    uint256 price,
    State state
  );

  event MarketItemSold (
    uint indexed id,
    address indexed nftContract,
    uint256 indexed tokenId,
    address seller,
    address buyer,
    uint256 price,
    State state
  );

  constructor() {
    marketowner = payable(msg.sender);
  }

  /**
   * @dev Returns the listing fee of the marketplace
   */
  function getListingFee() public view returns (uint256) {
    return listingFee;
  }

  /**
   * @dev create a MarketItem for NFT sale on the marketplace.
   * 
   * List an NFT.
   */
  function createMarketItem(
    address nftContract,
    uint256 tokenId,
    uint256 price
  ) public payable nonReentrant {

    require(price > 0, "Price must be at least 1 wei");
    require(msg.value == listingFee, "Fee must be equal to listing fee");

    require(IERC721(nftContract).getApproved(tokenId) == address(this), "NFT must be approved to market");

    // change to approve mechanism from the original direct transfer to market
    // IERC721(nftContract).transferFrom(msg.sender, address(this), tokenId);

    _itemCounter.increment();
    uint256 id = _itemCounter.current();

    marketItems[id] =  MarketItem(
      id,
      nftContract,
      tokenId,
      payable(msg.sender),
      payable(address(0)),
      price,
      State.Created
    );


    emit MarketItemCreated(
      id,
      nftContract,
      tokenId,
      msg.sender,
      address(0),
      price,
      State.Created
    );
  }

  /**
   * @dev delete a MarketItem from the marketplace.
   * 
   * de-List an NFT.
   * 
   * todo ERC721.approve can't work properly!! comment out
   */
  function deleteMarketItem(uint256 itemId) public nonReentrant {
    require(itemId <= _itemCounter.current(), "id must <= item count");
    require(marketItems[itemId].state == State.Created, "item must be on market");
    MarketItem storage item = marketItems[itemId];

    require(IERC721(item.nftContract).ownerOf(item.tokenId) == msg.sender, "must be the owner");
    require(IERC721(item.nftContract).getApproved(item.tokenId) == address(this), "NFT must be approved to market");

    item.state = State.Inactive;

    emit MarketItemSold(
      itemId,
      item.nftContract,
      item.tokenId,
      item.seller,
      address(0),
      0,
      State.Inactive
    );

  }

  /**
   * @dev (buyer) buy a MarketItem from the marketplace.
   * Transfers ownership of the item, as well as funds
   * NFT:         seller    -> buyer
   * value:       buyer     -> seller
   * listingFee:  contract  -> marketowner
   */
  function createMarketSale(
    address nftContract,
    uint256 id
  ) public payable nonReentrant {

    MarketItem storage item = marketItems[id]; //should use storge!!!!
    uint price = item.price;
    uint tokenId = item.tokenId;

    require(msg.value == price, "Please submit the asking price");
    require(IERC721(nftContract).getApproved(tokenId) == address(this), "NFT must be approved to market");

    IERC721(nftContract).transferFrom(item.seller, msg.sender, tokenId);

    payable(marketowner).transfer(listingFee);
    item.seller.transfer(msg.value);

    item.buyer = payable(msg.sender);
    item.state = State.Release;
    _itemSoldCounter.increment();    

    emit MarketItemSold(
      id,
      nftContract,
      tokenId,
      item.seller,
      msg.sender,
      price,
      State.Release
    );    
  }

  /**
   * @dev Returns all unsold market items
   * condition: 
   *  1) state == Created
   *  2) buyer = 0x0
   *  3) still have approve
   */
  function fetchActiveItems() public view returns (MarketItem[] memory) {
    return fetchHepler(FetchOperator.ActiveItems);
  }

  /**
   * @dev Returns only market items a user has purchased
   * todo pagination
   */
  function fetchMyPurchasedItems() public view returns (MarketItem[] memory) {
    return fetchHepler(FetchOperator.MyPurchasedItems);
  }

  /**
   * @dev Returns only market items a user has created
   * todo pagination
  */
  function fetchMyCreatedItems() public view returns (MarketItem[] memory) {
    return fetchHepler(FetchOperator.MyCreatedItems);
  }

  enum FetchOperator { ActiveItems, MyPurchasedItems, MyCreatedItems}

  /**
   * @dev fetch helper
   * todo pagination   
   */
   function fetchHepler(FetchOperator _op) private view returns (MarketItem[] memory) {     
    uint total = _itemCounter.current();

    uint itemCount = 0;
    for (uint i = 1; i <= total; i++) {
      if (isCondition(marketItems[i], _op)) {
        itemCount ++;
      }
    }

    uint index = 0;
    MarketItem[] memory items = new MarketItem[](itemCount);
    for (uint i = 1; i <= total; i++) {
      if (isCondition(marketItems[i], _op)) {
        items[index] = marketItems[i];
        index ++;
      }
    }
    return items;
  } 

  /**
   * @dev helper to build condition
   *
   * todo should reduce duplicate contract call here
   * (IERC721(item.nftContract).getApproved(item.tokenId) called in two loop
   */
  function isCondition(MarketItem memory item, FetchOperator _op) private view returns (bool){
    if(_op == FetchOperator.MyCreatedItems){ 
      return 
        (item.seller == msg.sender
          && item.state != State.Inactive
        )? true
         : false;
    }else if(_op == FetchOperator.MyPurchasedItems){
      return
        (item.buyer ==  msg.sender) ? true: false;
    }else if(_op == FetchOperator.ActiveItems){
      return 
        (item.buyer == address(0) 
          && item.state == State.Created
          && (IERC721(item.nftContract).getApproved(item.tokenId) == address(this))
        )? true
         : false;
    }else{
      return false;
    }
  }

}

Enter fullscreen mode Exit fullscreen mode

This NFTMarket contract can work, but it is not good. There are at least two jobs to be done:

  • We should add pagination in query functions. If there are thousands of items in the market, the query function can't work well.

  • When we try to verify whether a seller has already transferred the NFT item to others or has removed the approval to the market, we call nft.getApproved thousands of times. This is bad practice. We should try to figure out a solution.

We may also find that letting the webapp query data directly from a smart contract is not a good design. A data indexing layer is needed. The Graph Protocol and subgraph can do this job. You can find an explanation on how to use subgraph in dabit's NFT market tutorial.


Thinking Note on delegatecall

When I built the NFTMarketplace smart contract, I explored the wrong path for about one day and learned a lot. Here is what I learned.

  • When seller lists an NFT to marketplace, he gives market contract approval approve(marketaddress) to transfer NFT from seller to buyer by calling transferFrom(). I would like to choose not to use setApprovalForAll(operator, approved) which will give market contract approval of all my NFTs in one collection.

  • Seller may want to delete(de-list) an NFT from the market, so we add a function deleteMarketItem(itemId).

  • The wrong path starts here. I try to remove appoval for seller in the market contract.

    • Call nft.approve(address(0),tokenId) will revert. Market contract is not the owner of this NFT or approved for all operator.
    • Maybe we can using delegatecall which will be called using the original msg.sender(the seller). The seller is the owner.
    • I always get "Error: VM Exception while processing transaction: reverted with reason string 'ERC721: owner query for nonexistent token'". What's going wrong?
    • When I try to delegate call other functions such as name(), the result is not correct.
    • Dig, dig, and dig. Finally, I found that I misunderstood delegatecall. Delegatecall uses the storage of the caller(market contract), and it doesn't use the storage of the callee(nft contract). Solidity Docs writes: "Storage, current address and balance still refer to the calling contract, only the code is taken from the called address. "
    • So we can't delegate call nft.approve() to remove approval in market contract. We can't access the original data in the NFT contract by delegatecall.

delegatecall

The delegatecall code snippet (which is wrong):

    bytes memory returndata = Address.functionDelegateCall(
      item.nftContract, 
      abi.encodeWithSignature("approve(address,uint256)",address(0),1)
    );
    Address.verifyCallResult(true, returndata, "approve revert");
Enter fullscreen mode Exit fullscreen mode
  • But this is not the end. I finally found that I should not try to remove approval in the market contract. The logic is wrong.

    • Seller calls market contract deleteMarketItem to remove market item.
    • Seller doesn't ask market contract to call nft market "approve()" to remove the approval. (There is a ERC20Permit, but there is no permit in ERC721 yet.)
    • The design of blockchain don't allow contract to do this.
  • If seller want to do this, he should do it by himself by calling approve() directly. This is what we do in the unit test await nft.approve(ethers.constants.AddressZero,1)

Opensea suggest to use isApprovedForAll in its tutorial (sample code):

    /**
     * Override isApprovedForAll to whitelist user's OpenSea proxy accounts to enable gas-less listings.
     */
    function isApprovedForAll(address owner, address operator)
        override
        public
        view
        returns (bool)
    {
        // Whitelist OpenSea proxy contract for easy trading.
        ProxyRegistry proxyRegistry = ProxyRegistry(proxyRegistryAddress);
        if (address(proxyRegistry.proxies(owner)) == operator) {
            return true;
        }

        return super.isApprovedForAll(owner, operator);
    }

Enter fullscreen mode Exit fullscreen mode

Task 4.3: Unit test for NFTMarketplace (core function)

We will add two unit test scripts for NFTMarketplace:

  • one for core functions
  • one for query/fetch functions

The unit test scripts for core functions:

// NFTMarketplace.test.ts
import { expect } from "chai"
import { BigNumber, Signer } from "ethers"
import { ethers } from "hardhat"
import { BadgeToken, NFTMarketplace } from  "../typechain"
import { TransactionResponse, TransactionReceipt } from "@ethersproject/providers"

const _name='BadgeToken'
const _symbol='BADGE'

describe("NFTMarketplace", function () {
  let nft:BadgeToken
  let market:NFTMarketplace
  let account0:Signer,account1:Signer,account2:Signer
  let address0:string, address1:string, address2:string

  let listingFee:BigNumber
  const auctionPrice = ethers.utils.parseUnits('1', 'ether')

  beforeEach(async function () {
    [account0, account1, account2] = await ethers.getSigners()
    address0 = await account0.getAddress()
    address1 = await account1.getAddress()
    address2 = await account2.getAddress()

    const BadgeToken = await ethers.getContractFactory("BadgeToken")
    nft = await BadgeToken.deploy(_name,_symbol)

    const Market = await ethers.getContractFactory("NFTMarketplace")
    market = await Market.deploy()
    listingFee = await market.getListingFee()

  })

  it("Should create market item successfully", async function() {
    await nft.mintTo(address0)  //tokenId=1
    await nft.approve(market.address,1)
    await market.createMarketItem(nft.address, 1, auctionPrice, { value: listingFee })

    const items = await market.fetchMyCreatedItems()
    expect(items.length).to.be.equal(1)
  })

  it("Should create market item with EVENT", async function() {
    await nft.mintTo(address0)  //tokenId=1
    await nft.approve(market.address,1)
    await expect(market.createMarketItem(nft.address, 1, auctionPrice, { value: listingFee }))
      .to.emit(market, 'MarketItemCreated')
      .withArgs(
        1,
        nft.address,
        1,
        address0,
        ethers.constants.AddressZero,
        auctionPrice, 
        0)
  })

  it("Should revert to create market item if nft is not approved", async function() {
    await nft.mintTo(address0)  //tokenId=1
    // await nft.approve(market.address,1)
    await expect(market.createMarketItem(nft.address, 1, auctionPrice, { value: listingFee }))
      .to.be.revertedWith('NFT must be approved to market')
  })

  it("Should create market item and buy (by address#1) successfully", async function() {
    await nft.mintTo(address0)  //tokenId=1
    await nft.approve(market.address,1)
    await market.createMarketItem(nft.address, 1, auctionPrice, { value: listingFee })

    await expect(market.connect(account1).createMarketSale(nft.address, 1, { value: auctionPrice}))
      .to.emit(market, 'MarketItemSold')
      .withArgs(
        1,
        nft.address,
        1,
        address0,
        address1,
        auctionPrice, 
        1)

    expect(await nft.ownerOf(1)).to.be.equal(address1)

  })

  it("Should revert buy if seller remove approve", async function() {
    await nft.mintTo(address0)  //tokenId=1
    await nft.approve(market.address,1)
    await market.createMarketItem(nft.address, 1, auctionPrice, { value: listingFee })

    await nft.approve(ethers.constants.AddressZero,1)

    await expect(market.connect(account1).createMarketSale(nft.address, 1, { value: auctionPrice}))
      .to.be.reverted
  })

  it("Should revert buy if seller transfer the token out", async function() {
    await nft.mintTo(address0)  //tokenId=1
    await nft.approve(market.address,1)
    await market.createMarketItem(nft.address, 1, auctionPrice, { value: listingFee })

    await nft.transferFrom(address0,address2,1)

    await expect(market.connect(account1).createMarketSale(nft.address, 1, { value: auctionPrice}))
      .to.be.reverted
  })

  it("Should revert to delete(de-list) with wrong params", async function() {
    await nft.mintTo(address0)  //tokenId=1
    await nft.approve(market.address,1)
    await market.createMarketItem(nft.address, 1, auctionPrice, { value: listingFee })

    //not a correct id
    await expect(market.deleteMarketItem(2)).to.be.reverted

    //not owner
    await expect(market.connect(account1).deleteMarketItem(1)).to.be.reverted

    await nft.transferFrom(address0,address1,1)
    //not approved to market now
    await expect(market.deleteMarketItem(1)).to.be.reverted
  })

  it("Should create market item and delete(de-list) successfully", async function() {
    await nft.mintTo(address0)  //tokenId=1
    await nft.approve(market.address,1)

    await market.createMarketItem(nft.address, 1, auctionPrice, { value: listingFee })
    await market.deleteMarketItem(1)

    await nft.approve(ethers.constants.AddressZero,1)

    // should revert if trying to delete again
    await expect(market.deleteMarketItem(1))
      .to.be.reverted
  })

  it("Should seller, buyer and market owner correct ETH value after sale", async function() {
    let txresponse:TransactionResponse, txreceipt:TransactionReceipt
    let gas
    const marketownerBalance = await ethers.provider.getBalance(address0)

    await nft.connect(account1).mintTo(address1)  //tokenId=1
    await nft.connect(account1).approve(market.address,1)

    let sellerBalance = await ethers.provider.getBalance(address1)
    txresponse = await market.connect(account1).createMarketItem(nft.address, 1, auctionPrice, { value: listingFee })
    const sellerAfter = await ethers.provider.getBalance(address1)

    txreceipt = await txresponse.wait()
    gas = txreceipt.gasUsed.mul(txreceipt.effectiveGasPrice)

    // sellerAfter = sellerBalance - listingFee - gas
    expect(sellerAfter).to.equal(sellerBalance.sub( listingFee).sub(gas))

    const buyerBalance = await ethers.provider.getBalance(address2)
    txresponse =  await market.connect(account2).createMarketSale(nft.address, 1, { value: auctionPrice})
    const buyerAfter = await ethers.provider.getBalance(address2)

    txreceipt = await txresponse.wait()
    gas = txreceipt.gasUsed.mul(txreceipt.effectiveGasPrice)
    expect(buyerAfter).to.equal(buyerBalance.sub(auctionPrice).sub(gas))

    const marketownerAfter = await ethers.provider.getBalance(address0)
    expect(marketownerAfter).to.equal(marketownerBalance.add(listingFee))
  })
})

Enter fullscreen mode Exit fullscreen mode

Run:

yarn hardhat test test/NFTMarketplace.test.ts
Enter fullscreen mode Exit fullscreen mode

Results:

  NFTMarketplace
    ✓ Should create market item successfully (49ms)
    ✓ Should create market item with EVENT
    ✓ Should revert to create market item if nft is not approved
    ✓ Should create market item and buy (by address#1) successfully (48ms)
    ✓ Should revert buy if seller remove approve (49ms)
    ✓ Should revert buy if seller transfer the token out (40ms)
    ✓ Should revert to delete(de-list) with wrong params (49ms)
    ✓ Should create market item and delete(de-list) successfully (44ms)
    ✓ Should seller, buyer and market owner correct ETH value after sale (43ms)
  9 passing (1s)
Enter fullscreen mode Exit fullscreen mode

Task 4.4: Unit test for NFTMarketplace (query function)

The unit test scripts for query functions:

// NFTMarketplaceFetch.test.ts
import { expect } from "chai"
import { BigNumber, Signer } from "ethers"
import { ethers } from "hardhat"
import { BadgeToken, NFTMarketplace } from  "../typechain"

const _name='BadgeToken'
const _symbol='BADGE'

describe("NFTMarketplace Fetch functions", function () {
  let nft:BadgeToken
  let market:NFTMarketplace
  let account0:Signer,account1:Signer,account2:Signer
  let address0:string, address1:string, address2:string

  let listingFee:BigNumber
  const auctionPrice = ethers.utils.parseUnits('1', 'ether')

  beforeEach(async function () {
    [account0, account1, account2] = await ethers.getSigners()
    address0 = await account0.getAddress()
    address1 = await account1.getAddress()
    address2 = await account2.getAddress()

    const BadgeToken = await ethers.getContractFactory("BadgeToken")
    nft = await BadgeToken.deploy(_name,_symbol)
    // tokenAddress = nft.address

    const Market = await ethers.getContractFactory("NFTMarketplace")
    market = await Market.deploy()
    listingFee = await market.getListingFee()

    // console.log("1. == mint 1-6 to account#0")
    for(let i=1;i<=6;i++){
      await nft.mintTo(address0)
    }

    // console.log("3. == mint 7-9 to account#1")
    for(let i=7;i<=9;i++){
      await nft.connect(account1).mintTo(address1)
    }

    // console.log("2. == list 1-6 to market")
    for(let i=1;i<=6;i++){
      await nft.approve(market.address,i)
      await market.createMarketItem(nft.address, i, auctionPrice, { value: listingFee })
    }    
  })

  it("Should fetchActiveItems correctly", async function() {
    const items = await market.fetchActiveItems()
    expect(items.length).to.be.equal(6)
  })  

  it("Should fetchMyCreatedItems correctly", async function() {
    const items = await market.fetchMyCreatedItems()
    expect(items.length).to.be.equal(6)

    //should delete correctly
    await market.deleteMarketItem(1)
    const newitems = await market.fetchMyCreatedItems()
    expect(newitems.length).to.be.equal(5)
  })

  it("Should fetchMyPurchasedItems correctly", async function() {
    const items = await market.fetchMyPurchasedItems()
    expect(items.length).to.be.equal(0)
  })

  it("Should fetchActiveItems with correct return values", async function() {
    const items = await market.fetchActiveItems()

    expect(items[0].id).to.be.equal(BigNumber.from(1))
    expect(items[0].nftContract).to.be.equal(nft.address)
    expect(items[0].tokenId).to.be.equal(BigNumber.from(1))
    expect(items[0].seller).to.be.equal(address0)
    expect(items[0].buyer).to.be.equal(ethers.constants.AddressZero)
    expect(items[0].state).to.be.equal(0)//enum State.Created
  }) 

  it("Should fetchMyPurchasedItems with correct return values", async function() {
    await market.connect(account1).createMarketSale(nft.address, 1, { value: auctionPrice})
    const items = await market.connect(account1).fetchMyPurchasedItems()

    expect(items[0].id).to.be.equal(BigNumber.from(1))
    expect(items[0].nftContract).to.be.equal(nft.address)
    expect(items[0].tokenId).to.be.equal(BigNumber.from(1))
    expect(items[0].seller).to.be.equal(address0)
    expect(items[0].buyer).to.be.equal(address1)//address#1
    expect(items[0].state).to.be.equal(1)//enum State.Release

  })    

})

Enter fullscreen mode Exit fullscreen mode

Run:

yarn hardhat test test/NFTMarketplaceFetch.test.ts
Enter fullscreen mode Exit fullscreen mode

Results:

  NFTMarketplace Fetch functions
    ✓ Should fetchActiveItems correctly (48ms)
    ✓ Should fetchMyCreatedItems correctly (54ms)
    ✓ Should fetchMyPurchasedItems correctly
    ✓ Should fetchActiveItems with correct return values
    ✓ Should fetchMyPurchasedItems with correct return values
  5 passing (2s)
Enter fullscreen mode Exit fullscreen mode

Task 4.5: playMarket.ts helper script for developing smart contract

We write a script src/playMarket.ts. During the development and debug process, I run this script again and again. It helps me to see whether the market contract can work as I think.

// src/playMarket.ts
import { Signer } from "ethers"
import { ethers } from "hardhat"
import { BadgeToken, NFTMarketplace } from  "../typechain"

const base64 = require( "base-64")

const _name='BadgeToken'
const _symbol='BADGE'

async function main() {

  let account0:Signer,account1:Signer
  [account0, account1] = await ethers.getSigners()
  const address0=await account0.getAddress()
  const address1=await account1.getAddress()


  /* deploy the marketplace */
  const Market = await ethers.getContractFactory("NFTMarketplace")
  const market:NFTMarketplace = await Market.deploy()
  await market.deployed()
  const marketAddress = market.address

  /* deploy the NFT contract */
  const NFT = await ethers.getContractFactory("BadgeToken")
  const nft:BadgeToken = await NFT.deploy(_name,_symbol)
  await nft.deployed()
  const tokenAddress = nft.address

  console.log("marketAddress",marketAddress)
  console.log("nftContractAddress",tokenAddress)

  /* create two tokens */
  await nft.mintTo(address0) //'1'
  await nft.mintTo(address0) //'2' 
  await nft.mintTo(address0) //'3'

  const listingFee = await market.getListingFee()
  const auctionPrice = ethers.utils.parseUnits('1', 'ether')

  await nft.approve(marketAddress,1)
  await nft.approve(marketAddress,2)
  await nft.approve(marketAddress,3)
  console.log("Approve marketAddress",marketAddress)

  // /* put both tokens for sale */
  await market.createMarketItem(tokenAddress, 1, auctionPrice, { value: listingFee })
  await market.createMarketItem(tokenAddress, 2, auctionPrice, { value: listingFee })
  await market.createMarketItem(tokenAddress, 3, auctionPrice, { value: listingFee })

  // test transfer
  await nft.transferFrom(address0,address1,2)

  /* execute sale of token to another user */
  await market.connect(account1).createMarketSale(tokenAddress, 1, { value: auctionPrice})

  /* query for and return the unsold items */
  console.log("==after purchase & Transfer==")

  let items = await market.fetchActiveItems()
  let printitems
  printitems = await parseItems(items,nft)
  printitems.map((item)=>{printHelper(item,true,false)})
  // console.log( await parseItems(items,nft))

  console.log("==after delete==")
  await market.deleteMarketItem(3)

  items = await market.fetchActiveItems()
  printitems = await parseItems(items,nft)
  printitems.map((item)=>{printHelper(item,true,false)})
  // console.log( await parseItems(items,nft))

  console.log("==my list items==")
  items = await market.fetchMyCreatedItems()
  printitems = await parseItems(items,nft)
  printitems.map((item)=>{printHelper(item,true,false)})

  console.log("")
  console.log("==address1 purchased item (only one, tokenId =1)==")
  items = await market.connect(account1).fetchMyPurchasedItems()
  printitems = await parseItems(items,nft)
  printitems.map((item)=>{printHelper(item,true,true)})

}

async function parseItems(items:any,nft:BadgeToken) {
  let parsed=  await Promise.all(items.map(async (item:any) => {
    const tokenUri = await nft.tokenURI(item.tokenId)
    return {
      price: item.price.toString(),
      tokenId: item.tokenId.toString(),
      seller: item.seller,
      buyer: item.buyer,
      tokenUri
    }
  }))

  return parsed
}

function printHelper(item:any,flagUri=false,flagSVG=false){
  if(flagUri){
    const {name,description,svg}= parseNFT(item)
    console.log("id & name:",item.tokenId,name)
    if(flagSVG) console.log(svg)
  }else{
    console.log("id       :",item.tokenId)
  }
}

function parseNFT(item:any){
  const data = base64.decode(item.tokenUri.slice(29))
  const itemInfo = JSON.parse(data)
  const svg = base64.decode(itemInfo.image.slice(26))
  return(
    {"name":itemInfo.name,
     "description":itemInfo.description,
     "svg":svg})  
}

main().catch((error) => {
  console.error(error)
  process.exitCode = 1
})

Enter fullscreen mode Exit fullscreen mode

What we do in this script:

  • deploy BadgeToken NFT and NFTMarketplace
  • mint 3 NFT items to address0
  • approve 3 NFT items to the market contract
  • list 3 NFT items to NFTMarketplace
  • transfer Badge #3 to other
  • the listed items should be #1,#2
  • address1(account1) buy Badge #1
  • address1 purchased item should be #1
  • print tokenId, name, svg for inspection

Run:

yarn hardhat run src/playMarket.ts
Enter fullscreen mode Exit fullscreen mode

Result:

marketAddress 0x5FbDB2315678afecb367f032d93F642f64180aa3
nftContractAddress 0xe7f1725E7734CE288F8367e1Bb143E90bb3F0512
Approve marketAddress 0x5FbDB2315678afecb367f032d93F642f64180aa3
==after purchase & Transfer==
id & name: 3 Badge #3
==after delete==
==my list items==
id & name: 1 Badge #1
id & name: 2 Badge #2

==address1 purchased item svg (only one, tokenId =1)==
id & name: 1 Badge #1
<svg xmlns='http://www.w3.org/2000/svg' preserveAspectRatio='xMinYMin meet' viewBox='0 0 350 350'><style>.base { fill: white; font-family: serif; font-size: 300px; }</style><rect width='100%' height='100%' fill='brown' /><text x='100' y='260' class='base'>1</text></svg>
✨  Done in 4.42s.
Enter fullscreen mode Exit fullscreen mode

Task 4.6: Scripts to prepare for webapp

We need to prepare data for webapp:

  • 1-6 by Account#0, 1- Account1, 2- Account#2
  • 7-9 by Account#1, 7,8 - Account#0
  • In market: 3,4,5,9 (6 delist by Account#0)
  • Account#0:Buy 7,8, List:1-5( 6 delisted)
  • Account#1:Buy 1, List:7-9
  • Account#2:Buy 2, List:n/a
// src/prepare.ts
import { Signer } from "ethers"
import { ethers } from "hardhat"
import { BadgeToken, NFTMarketplace } from  "../typechain"
import { tokenAddress, marketAddress } from "./projectsetting"

const _name='BadgeToken'
const _symbol='BADGE'

async function main() {

  console.log("========   deploy to a **new** localhost ======")

  /* deploy the NFT contract */
  const NFT = await ethers.getContractFactory("BadgeToken")
  const nftContract:BadgeToken = await NFT.deploy(_name,_symbol)
  await nftContract.deployed()

  /* deploy the marketplace */
  const Market = await ethers.getContractFactory("NFTMarketplace")
  const marketContract:NFTMarketplace = await Market.deploy()

  console.log("nftContractAddress:",nftContract.address)
  console.log("marketAddress     :",marketContract.address)

  console.log("========   Prepare for webapp dev ======")
  console.log("nftContractAddress:",tokenAddress)
  console.log("marketAddress     :",marketAddress)
  console.log("**should be the same**")

  let owner:Signer,account1:Signer,account2:Signer

  [owner, account1,account2] = await ethers.getSigners()
  const address0 = await owner.getAddress()
  const address1 = await account1.getAddress()
  const address2 = await account2.getAddress()

  const market:NFTMarketplace = await ethers.getContractAt("NFTMarketplace", marketAddress)
  const nft:BadgeToken = await ethers.getContractAt("BadgeToken", tokenAddress)

  const listingFee = await market.getListingFee()
  const auctionPrice = ethers.utils.parseUnits('1', 'ether')

  console.log("1. == mint 1-6 to account#0")
  for(let i=1;i<=6;i++){
    await nft.mintTo(address0)
  }

  console.log("2. == list 1-6 to market")
  for(let i=1;i<=6;i++){
    await nft.approve(marketAddress,i)
    await market.createMarketItem(tokenAddress, i, auctionPrice, { value: listingFee })
  }

  console.log("3. == mint 7-9 to account#1")
  for(let i=7;i<=9;i++){
    await nft.connect(account1).mintTo(address1)
  }

  console.log("4. == list 1-6 to market")
  for(let i=7;i<=9;i++){
    await nft.connect(account1).approve(marketAddress,i)
    await market.connect(account1).createMarketItem(tokenAddress, i, auctionPrice, { value: listingFee })
  }

  console.log("5. == account#0 buy 7 & 8")
  await market.createMarketSale(tokenAddress, 7, { value: auctionPrice})
  await market.createMarketSale(tokenAddress, 8, { value: auctionPrice})

  console.log("6. == account#1 buy 1")
  await market.connect(account1).createMarketSale(tokenAddress, 1, { value: auctionPrice})

  console.log("7. == account#2 buy 2")
  await market.connect(account2).createMarketSale(tokenAddress, 2, { value: auctionPrice})

  console.log("8. == account#0 delete 6")
  await market.deleteMarketItem(6)

}

main().catch((error) => {
  console.error(error)
  process.exitCode = 1
})

Enter fullscreen mode Exit fullscreen mode

Run a stand-alone local testnet in another terminal:

yarn hardhat node
Enter fullscreen mode Exit fullscreen mode

Run:

yarn hardhat run src/prepare.ts --network localhost
Enter fullscreen mode Exit fullscreen mode

Results:

========   deploy to a **new** localhost ======
nftContractAddress: 0x5FbDB2315678afecb367f032d93F642f64180aa3
marketAddress     : 0xe7f1725E7734CE288F8367e1Bb143E90bb3F0512
========   Prepare for webapp dev ======
nftContractAddress: 0x5FbDB2315678afecb367f032d93F642f64180aa3
marketAddress     : 0xe7f1725E7734CE288F8367e1Bb143E90bb3F0512
**should be the same**
1. == mint 1-6 to account#0
2. == list 1-6 to market
3. == mint 7-9 to account#1
4. == list 1-6 to market
5. == account#0 buy 7 & 8
6. == account#1 buy 1
7. == account#2 buy 2
8. == account#0 delete 6
✨  Done in 5.81s.
Enter fullscreen mode Exit fullscreen mode

task 5

Task 5: Webapp for NFTMarketplace

Task 5.1: add component ReadNFTMarket

Currently, we query market contract directly instead of using SWR in this code snippet.

// components/ReadNFTMarket.tsx
import React from 'react'
import { useEffect,useState } from 'react';
import { useWeb3React } from '@web3-react/core'
import { Web3Provider } from '@ethersproject/providers'
import { Contract } from "@ethersproject/contracts";
import { Grid, GridItem, Box, Text, Button } from "@chakra-ui/react"
import { BigNumber, ethers } from 'ethers';
import useSWR from 'swr'
import { addressNFTContract, addressMarketContract }  from '../projectsetting'
import  CardERC721  from "./CardERC721"

interface Props {
    option: number
}

export default function ReadNFTMarket(props:Props){
  const abiJSON = require("abi/NFTMarketplace.json")
  const abi = abiJSON.abi
  const [items,setItems] = useState<[]>()

  const {  account, active, library} = useWeb3React<Web3Provider>()

  // const { data: items} = useSWR([addressContract, 'fetchActiveItems'], {
  //   fetcher: fetcher(library, abi),
  // })

useEffect( () => {
    if(! active)
      setItems(undefined)

    if(!(active && account && library)) return

    // console.log(addressContract,abi,library)
    const market:Contract = new Contract(addressMarketContract, abi, library);
    console.log(market.provider)
    console.log(account)

    library.getCode(addressMarketContract).then((result:string)=>{
      //check whether it is a contract
      if(result === '0x') return

      switch(props.option){
        case 0:
          market.fetchActiveItems({from:account}).then((items:any)=>{
            setItems(items)
          })    
          break;
        case 1:
          market.fetchMyPurchasedItems({from:account}).then((items:any)=>{
            setItems(items)
          })    
          break;
        case 2:
          market.fetchMyCreatedItems({from:account}).then((items:any)=>{
            setItems(items)
            console.log(items)
          })    
          break;
        default:
      }

    })

    //called only when changed to active
},[active,account])


async function buyInNFTMarket(event:React.FormEvent,itemId:BigNumber) {
  event.preventDefault()

  if(!(active && account && library)) return

  //TODO check whether item is available beforehand

  const market:Contract = new Contract(addressMarketContract, abi, library.getSigner());
  const auctionPrice = ethers.utils.parseUnits('1', 'ether')
  market.createMarketSale(
      addressNFTContract, 
      itemId, 
      { value: auctionPrice}
    ).catch('error', console.error)
}

const state = ["On Sale","Sold","Inactive"]

return (
  <Grid templateColumns='repeat(3, 1fr)' gap={0} w='100%'>

    {items
    ? 
    (items.length ==0)
      ?<Box>no item</Box>
      :items.map((item:any)=>{
        return(
          <GridItem key={item.id} >
            <CardERC721 addressContract={item.nftContract} tokenId={item.tokenId} ></CardERC721>
            <Text fontSize='sm' px={5} pb={1}> {state[item.state]} </Text> 
            {((item.seller == account && item.buyer == ethers.constants.AddressZero) || (item.buyer == account))
            ?<Text fontSize='sm' px={5} pb={1}> owned by you </Text> 
            :<Text></Text>
            }
            <Box>{
            (item.seller != account && item.state == 0)
            ? <Button width={220} type="submit" onClick={(e)=>buyInNFTMarket(e,item.id)}>Buy this!</Button>
            : <Text></Text>
            }
            </Box>
          </GridItem>)
      })
    :<Box></Box>}
  </Grid>

  )
}
Enter fullscreen mode Exit fullscreen mode

Task 5.2: add ReadNFTMarket to index

We add three ReadNFTMarket to index.tsx:

  • one for all market items
  • one for my purchased items
  • one for my created items

dapp

Task 5.3: Run the DApp

STEP 1: New local testnt

In another terminal, run in chain/

yarn hardhat node
Enter fullscreen mode Exit fullscreen mode

STEP 2: prepare data for webapp
Run in chain/

yarn hardhat run src/prepare.ts --network localhost
Enter fullscreen mode Exit fullscreen mode

STEP 3: run webapp

Run in webapp/

yarn dev
Enter fullscreen mode Exit fullscreen mode

STEP 4: browser http://localhost:3000/ and connect MetaMask

STEP 5: buy Badge #9 as Account#0

STEP 6: switch to Account#1 in MetaMask, buy Badge #3

Now you have an NFT marketplace. Congratulations.


You can continue to deploy it to public testnet(ropsten), ethereum mainnet, sideschain(BSC/Polygon), Layer2(Arbitrum/Optimism).

You have more to do with smart contract development. Take one for example:

  • You first version of NFTMarketpalce works well. Several weeks later, you find that you need to add new functionality to NFTMarketplace.

  • A smart contract is immutable. Deploying a new version of NFTMarketplace and asking users to list their NFT to the new contract is not a good idea.

  • Now you need upgradeable smart contract (proxy contract pattern). You can learn how to develop proxy contract in our previous tutorial: Tutorial: write upgradeable smart contract (proxy) using OpenZeppelin.


Tutorial List:

1. A Concise Hardhat Tutorial(3 parts)

https://dev.to/yakult/a-concise-hardhat-tutorial-part-1-7eo

2. Understanding Blockchain with ethers.js(5 parts)

https://dev.to/yakult/01-understanding-blockchain-with-ethersjs-4-tasks-of-basics-and-transfer-5d17

3. Tutorial : build your first DAPP with Remix and Etherscan (7 Tasks)

https://dev.to/yakult/tutorial-build-your-first-dapp-with-remix-and-etherscan-52kf

4. Tutorial: build DApp with Hardhat, React and ethers.js (6 Tasks)

https://dev.to/yakult/a-tutorial-build-dapp-with-hardhat-react-and-ethersjs-1gmi

5. Tutorial: build DAPP with Web3-React and SWR(5 Tasks)

https://dev.to/yakult/tutorial-build-dapp-with-web3-react-and-swr-1fb0

6. Tutorial: write upgradeable smart contract (proxy) using OpenZeppelin(7 Tasks)

https://dev.to/yakult/tutorial-write-upgradeable-smart-contract-proxy-contract-with-openzeppelin-1916

7. Tutorial: Build an NFT marketplace DApp like Opensea(5 Tasks)

https://dev.to/yakult/tutorial-build-a-nft-marketplace-dapp-like-opensea-3ng9


If you find this tutorial helpful, follow me at Twitter @fjun99

Discussion (11)

Collapse
sirius198 profile image
Sirius198

Hi, Thanks for your post.
I have a question.
Y'know, there are so many collections on OpenSea or other marketplaces.
But if I develop marketplace like above, there would be only one NFT collection.
What if I need to create NFT collections on marketplace?

Collapse
yakult profile image
fangjun Author • Edited on

this is just a demo.

to support multi collections, you need to do more.

you can refer to the newly released opensea protocol - seaport.

It's great.

github.com/ProjectOpenSea/seaport

the current opensea protocol wyvern protocol is also good. but I think you can dive into seaport directly.

Collapse
sirius198 profile image
Sirius198 • Edited on

Thanks.
But Opensea only works for Ethereum and Solana.
I would like to create a nft marketplace on another blockchain network like Cosmos.
I want to know if the nft collection is stored on a blockchain or in a database like MySQL.
Apps like dappRadar tracks trending nft collections, so I thought there was a protocol for nft collections like ERC721.
Will seaport repository help me find a solution?

Collapse
fullstackwebdeveloper profile image
FullstackWEB-developer • Edited on

Hello, Thank you for your content.
I have a question.
"There is a ERC20Permit, but there is no permit in ERC721 yet."
Please tell me about this content
I found this blog somewhere soliditydeveloper.com/erc721-permit
I can't use above method?
I have to use only proxy resister method like as opensea?

Collapse
yakult profile image
fangjun Author

If I have time in the next two weeks, I can prepare a tutorial on permit both for ERC20 and ERC721.

Collapse
liahimdiazzeddine profile image
Hamman Liahimdi Azzeddine

Thanks for the great content.
I got one question tho. if nft has bin buy in another market, how can we cancel or delete the item 'nft' from sale in our market?????

Collapse
yakult profile image
fangjun Author

Good questions. I will dig how Opensea implement this logic.

Collapse
klammer_andreas profile image
Andreas Klammer

Hey Man! Thanks for the great content.
I got one question tho. Is it the same procedure if I want to build a Dapp for fungable tokens f.e. ERC 20 Tokens?

Collapse
yakult profile image
fangjun Author

yes, same process, different logic

Collapse
klammer_andreas profile image
Andreas Klammer

Thanks!
In which way is the logic different? Is their even a short explenation?