DEV Community

fangjun
fangjun

Posted on • Edited on

Web3 Tutorial: Build an NFT marketplace DApp like OpenSea

Building an NFT marketplace DApp like Opensea with Solidity and Javascript/React may be an important step in your web3 development journey. Let's go.

In my previous tutorials, you may have learned:

  • how to write a smart contract and run it on local blockchain 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 user's wallet.

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

Now you can start writing a smart contract with complete functionalities: a marketplace for digital items. NFT items of an NFT collection are the digital items traded here.

Table of content


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

You may already read my previous tutorials. If not, I suggest you read the following two as I will not explain some tactics which are already there.

Let's start building.


Task 1

Task 1: What we build and project setup

Task 1.1: What we build - three parts

  • An NFT collection 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 this sample NFT collection to work with in the marketplace contract as well as in the storefront.

  • An NFT marketplace smart contract in which user can list an NFT item and buy an NFT item. Seller can also delist his own NFT from the market. This marketplace contract also provides query functions for webapp to query market data. 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 will not provide UI components for sellers to list NFTs in the market in the webapp. )

The key 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 the buyer paid

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

  • a buyer buys an NFT by paying the price value
  • market contract completes the purchase process:
    • transfer the price value to the 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, there are 3 major differences between what we will build and his:

  • Dabit's NFT is a traditional one which stores images on IPFS while our NFT stores SVG images on-chain (just data, not image). We use this option to make our tutorial simple as we don't need to setup a server to provide NFT tokenURI (restful json api) and 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 marketplace functionality in one smart contract. We choose to separate them here as we would like to build a general purpose NFT marketplace.

  • In Dabit's tutorial, when a seller lists an NFT item to marketplace, he transfers the NFT item to market contract and waits for it to be sold. As a blockchain and web3.0 user, I don't like this pattern. I would like to approve only the NFT item to the marketplace contract. And before it is sold, the item is still in my wallet. (I also would like not to use setApprovalForAll() to approve all the NFT items in this collection in my address to the market contract. We choose to approve NFT 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 hardhat chain starter project from github repo. In your nftmarket directory, run:

git clone git@github.com:fjun99/chain-tutorial-hardhat-starter.git chain
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
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
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 implementation. We add three functionalities here:

  • tokenId: auto increment tokenId starting from 1
  • function mintTo(address _to): everyone can call it to mint an NFT
  • function 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 script scripts/deploy_BadgeToken.ts which will deploy this NFT contract 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 implementation of ERC721 function tokenURI() .

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. You 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 instead:

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 it to get the name, description and SVG image.

The SVG image is adapted from the LOOT project. It is very simple. It displays 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 have 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 have 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 on 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 a lot. But you should note that we make a lot of changes 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

Each market item can be in 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 will still be Created indicating others can buy in the market. Actually others 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 who is the contract deployer. Listing fee will be going to market owner when a listed NFT item is sold in the market.

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

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 has two categories of methods:

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

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 the 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 a seller lists an NFT to the 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 am trying to remove approval for the seller in the market contract.

    • Call nft.approve(address(0),tokenId) will revert. The market contract is not the owner of this NFT or approved for all as an 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 contract "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 the seller wants 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 suggests 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

The "approve for all" mechanism is quite complicated and you can refer to the opensea proxy contract for more information.


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

Unit test script 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)

Unit test script 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 it is designed to.

// 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 the 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: run a new local testnet

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

Set your MetaMask's mnemonics the Hardhat pre-defined ref link and add the accounts in it:

test test test test test test 
test test test test test junk
Enter fullscreen mode Exit fullscreen mode

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, sidechain(BSC/Polygon), Layer2(Arbitrum/Optimism).


Task 1

Optional Task 6: Deploy to Polygon and query using Alchemy NFT API

Task 6.1 Deploy to Polygon

In this optional task, I will deploy the NFT contract and the NFTMarketplace contract to Polygon mainnet as the gas fee is ok. You can also choose to deploy to Ethereum testnet(Goerli), Polygon testnet(Mumbai) or Layer 2 testnet(such as Arbitrum Goerli).

STEP 1. Edit .env with Alchemy URL with key, your private key for testing, Polygonscan API key. You may need to add polygon in your hardhat.config.ts

POLYGONSCAN_API_KEY=ABC123ABC123ABC123ABC123ABC123ABC1
POLYGON_URL=https://polygon-mainnet.g.alchemy.com/v2/<YOUR ALCHEMY KEY>
POLYGON_PRIVATE_KEY=0xabc123abc123abc123abc123abc123abc123abc123abc123abc123abc123abc1
Enter fullscreen mode Exit fullscreen mode

STEP 2. Deploy the NFT contract and verify on polygonscan.com. Run:

yarn hardhat run scripts/deploy_BadgeToken.ts --network polygon
// BadgeToken deployed to: 0x1FC8b9DC757FD50Bfec8bbE103256F176435faEE

yarn hardhat verify --network polygon 0x1FC8b9DC757FD50Bfec8bbE103256F176435faEE 'BadgeToken' 'BADGE'
// Successfully verified contract BadgeToken on Etherscan.
// https://polygonscan.com/address/0x1FC8b9DC757FD50Bfec8bbE103256F176435faEE#code
Enter fullscreen mode Exit fullscreen mode

STEP 3. Deploy the NFTMarketplace and verify

yarn hardhat run scripts/deploy_Marketplace.ts --network polygon
// NFTMarketplace deployed to: 0x2B7302B1ABCD30Cd475d78688312529027d57bEf

yarn hardhat verify --network polygon 0x2B7302B1ABCD30Cd475d78688312529027d57bEf
// Successfully verified contract NFTMarketplace on Etherscan.
// https://polygonscan.com/address/0x2B7302B1ABCD30Cd475d78688312529027d57bEf#code
Enter fullscreen mode Exit fullscreen mode

Task 6.2 Mint NFT and list on marketplace

STEP 4. Mint one NFT (tokenId=1) to your testing account on https://polygonscan.com/

You can view the NFT "Badge #1" on opensea: https://opensea.io/assets/matic/0x1fc8b9dc757fd50bfec8bbe103256f176435faee/1

STEP 5. List your NFT item "Badge #1" to NFTMarketpalce contract on https://polygonscan.com/

First you need to approve the NFT item "Badge #1" to the NFTMarketpalce.

Then you call CreateMarketItem().

STEP 6. Run the webapp. After connecting the wallet, you can see the item in the market.

Note: remember to edit the NFT contract and NFTMarketpalce contract address in webapp/src/projectsetting.ts.

deploy and mint


Task 6.3 Query NFT using Alchemy NFT APIs

Now, we can switch to using Alchemy NFT APIs(docs link) to query NFT data and display it in our webapp.

Let's try it. We will use Alchemy SDK here for demonstration.

yarn add alchemy-sdk
Enter fullscreen mode Exit fullscreen mode

The code snippet is adapted from Alchemy NFT APIs docs(link). You will need an Alchemy API Key to run it.

// This script demonstrates access to the NFT API via the Alchemy SDK.
import { Network, Alchemy } from "alchemy-sdk";
import  base64  from  "base-64"

const settings = {
    apiKey: "Your Alchemy API Key",
    network: Network.MATIC_MAINNET,
};

const alchemy = new Alchemy(settings);

const addressNFTContract = "0x1FC8b9DC757FD50Bfec8bbE103256F176435faEE"
const owner = await alchemy.nft.getOwnersForNft(addressNFTContract, "1")

console.log("Badge #1 owner:", owner )

// Print NFT metadata returned in the response:
const metadata = await alchemy.nft.getNftMetadata(
    addressNFTContract,
    "1"
  )

console.log("tokenURI:", metadata.tokenUri)
const media = metadata.media[0].raw
console.log("media:", media)

const svg = base64.decode(media.slice(26))
console.log(svg)
Enter fullscreen mode Exit fullscreen mode

Results:

Badge #1 owner: { owners: [ '0x08e2af90ff53a3d3952eaa881bf9b3c05e893462' ] }
tokenURI: {
  raw: 'data:application/json;base64,eyJuYW...',
  gateway: ''
}
media: data:image/svg+xml;base64,PHN2...

<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

That is it. We have developed a super simplified version of Opensea including contract and webapp. There is a lot of work to be done. Take one for example:

  • Your 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 my another 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

Top comments (23)

Collapse
 
starton profile image
Starton

Hi, I came across your tutorials by chance
Impressive work ๐Ÿ‘

Collapse
 
jonasand profile image
Jonas Andersen

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 • Edited

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
 
jonasand profile image
Jonas Andersen • Edited

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
 
t_p profile image
t_p

@yakult can you suggest a guide or tutorial to getting started building a martketplace with seaport

Thread Thread
 
yakult profile image
fangjun

have not read one. And I will work on one.

Thread Thread
 
t_p profile image
t_p

that would be awesome

Collapse
 
johnram98468527 profile image
Info Comment hidden by post author - thread only accessible via permalink
JohnRamsey

Hi all. If you are just starting to get acquainted with NFT, then you probably have a lot of questions. Or perhaps you have heard of such a thing as blue chips, but do not yet understand what it means, so I want to share with you the article blog.alphaguilty.io/what-is-a-blue... , which explains it in detail. This information will be useful to everyone, including those who already have an NFT project, as they told how to get into the league.

Collapse
 
samsmith277 profile image
Kimberly

Quality UI design is paramount in the development of decentralized applications (DApps), significantly influencing user experience through intuitive navigation, visual appeal, and adaptability to changes, ultimately fostering trust, brand consistency, and user retention in the dynamic landscape of decentralized technologies. So when creating DAPP be sure to read the article: dapp ui

Collapse
 
fullstackwebdeveloper profile image
FullstackWEB-developer • Edited

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

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

Collapse
 
merclown profile image
Merclown

Im trying to contact you i have a suspect site that use some Daaps to specific wallets. I lost all my NFTs suffering a wallet draining. Can you check if that site have any bad Daaps? Please contact me my email merclown@gmail.com.

Collapse
 
jacquelinebuo profile image
Jacqueline-buo

Remember, investing in Bitcoin is not an all-or-nothing decision. You can start with an amount that you are comfortable with, and together, we will grow your investment over time. The world of Bitcoin investment offers exciting opportunities, and I genuinely believe that with my guidance and support, you can confidently embark on this journey towards financial empowerment and potential prosperity. If youโ€™re ready to take the first step or have any questions, please feel free to reach out. I am here to help you every step of the way
Email::: jacquiline.buo@gmail.com
facebook.com/jacquelinebu0

Collapse
 
imintify profile image
iMintify

It's a nice, impressive blog for technical users and a great guide. For the non-tech guys who want to create their own NFT Marketplace no-code, check out the app we have launched.

Just have to create a smart contract, upload the nft images and launch a marketplace for free.

imintify.com/

Image description

Collapse
 
serv3035 profile image
Sette AWer

Hi. Can you take a few minutes and read this letter for me?!
Tether Gold (XAUt) is a token that provides you ownership of real physical gold.
By putting gold on a Blockchain, we unlock a variety of characteristics that typically only crypto assets possess.ย 
When you connect to our platform, we offer a $500 deposit bonus on BayBit and commission-free top-ups.
Gold Tether AIRDROP
cutt.ly/IwjVg252

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

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

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?

Some comments have been hidden by the post's author - find out more