Учебник Web3: Создание NFT marketplace DApp наподобие OpenSea

Создание NFT marketplace DApp, подобного Opensea, с помощью Solidity и Javascript/React может стать важным шагом на вашем пути web3-разработки. Давайте приступим.

В моих предыдущих уроках вы, возможно, узнали:

  • как написать смарт-контракт и запустить его на локальном блокчейн-тестнете с помощью hardhat.

  • как создать полностековый DApp, что означает работу со всеми тремя частями DApp: смарт-контрактом, веб-приложением и кошельком пользователя.

  • как провести модульное тестирование, развернуть в публичной тестовой сети, проверить смарт-контракт в блокчейн-проводнике Etherscan.

Теперь вы можете начать писать смарт-контракт с полным функционалом: торговая площадка для цифровых товаров. Цифровыми товарами, торгуемыми здесь, являются предметы коллекции NFT.

Таблица содержания

  • Задача 1: Что мы создаем и настройка проекта
  • Задача 2: Смарт-контракт коллекции NFT
  • Задача 3: Веб-страница для отображения элементов NFT
  • Задача 4: Смарт-контракт NFT marketplace
  • Задача 5: Webapp для NFTMarketplace
  • Задача 6: Развертывание на Polygon и запрос с помощью API Alchemy NFT.

Надер Дабит написал две версии книги How to Build a Full Stack NFT Marketplace — V2 (2022). Вдохновленный его идеей и основываясь на его кодовой базе смарт-контрактов, я написал больше кода и написал это руководство для вас.

Возможно, вы уже читали мои предыдущие руководства. Если нет, советую прочитать следующие два, так как я не буду объяснять некоторые тактики, которые уже есть.

  • Web3 Tutorial: создание DApp с помощью Hardhat, React и Ethers.js https://dev.to/yakult/a-tutorial-build-dapp-with-hardhat-react-and-ethersjs-1gmi

  • Web3 Tutorial: build DApp with Web3-React and SWR https://dev.to/yakult/tutorial-build-dapp-with-web3-react-and-swr-1fb0

Давайте начнем строить.


Задача 1: Что мы собираем и настройка проекта

Задача 1.1: Что мы создаем — три части

  • Смарт-контракт для сбора NFT и простая веб-страница для отображения элемента NFT. Мы будем использовать SVG на цепочке в качестве изображения элемента NFT. Нам нужен этот образец коллекции NFT для работы с контрактом торговой площадки, а также для витрины магазина.

  • Смарт-контракт торговой площадки NFT, в котором пользователь может разместить в списке товар NFT и купить товар NFT. Продавец также может удалить свой собственный NFT с рынка. Этот контракт рынка также предоставляет функции запроса для webapp для запроса данных рынка. Мы постараемся как можно больше описать этот смарт-контракт с помощью модульных тестов.

  • Витрина рынка NFT, использующая React/Web3-React/SWR. (Для упрощения мы создадим только необходимые компоненты витрины в одностраничном webapp. Например, мы не будем предоставлять в webapp компоненты пользовательского интерфейса для продавцов, чтобы они могли выставлять НМТ на продажу. )

Ключевой частью этого проекта является смарт-контракт торговой площадки (NFTMarketplace) с хранилищем данных, основными функциями и функциями запросов.

Основные функции:

function createMarketItem(address nftContract,uint256 tokenId,uint256 price) payable 
function deleteMarketItem(uint256 itemId) public
function createMarketSale(address nftContract,uint256 id) public payable
Вход в полноэкранный режим Выход из полноэкранного режима

Функции запросов:

function fetchActiveItems() public view returns (MarketItem[] memory) 
function fetchMyPurchasedItems() public view returns (MarketItem[] memory)
function fetchMyCreatedItems() public view returns (MarketItem[] memory) 
Войти в полноэкранный режим Выход из полноэкранного режима

Продавец может использовать смарт-контракт, чтобы:

  • утвердить контракт NFT на продажу
  • создать рыночный товар с платой за листинг
  • …(ожидая покупателя, который купит НМТ)…
  • получить стоимость, которую заплатил покупатель

Когда покупатель совершает покупку на рынке, рыночный контракт облегчает процесс покупки:

  • покупатель приобретает НМТ, заплатив стоимость цены
  • рыночный контракт завершает процесс покупки:
    • передает продавцу стоимость цены
    • передача НМТ от продавца к покупателю
    • перечисление платы за листинг владельцу рынка
    • изменить состояние рыночного товара с Created на Release.

GitHub repos этого руководства:

  • смарт-контракты (проект hardhat): https://github.com/fjun99/nftmarketplace
  • веб-приложение с использованием React: https://github.com/fjun99/web3app-tutrial-using-web3react (ветка nftmarket).

Хотя я многому научился из учебника по NFT marketplace от Dabit, есть 3 основных отличия между тем, что мы будем создавать, и его учебником:

  • NFT Дабита — это традиционный NFT, который хранит изображения на IPFS, в то время как наш NFT хранит SVG изображения на цепочке (только данные, не изображение). Мы используем этот вариант, чтобы сделать наш учебник простым, так как нам не нужно настраивать сервер для предоставления NFT tokenURI (restful json api) и иметь дело с хранением изображений на сервере или IPFS.

  • В первой версии учебника Дабит разделил смарт-контракт токена NFT ERC721 и смарт-контракт рынка. Во второй версии он решил построить смарт-контракт NFT ERC721 с функциональностью маркетплейса в одном смарт-контракте. Мы решили разделить их здесь, поскольку хотим построить NFT-маркетплейс общего назначения.

  • В учебнике Dabit, когда продавец выставляет товар NFT на marketplace, он передает товар NFT в рыночный контракт и ждет, пока он будет продан. Мне, как пользователю блокчейна и web3.0, такая схема не нравится. Я бы хотел, чтобы в рыночный контракт утверждался только товар NFT. И пока он не продан, товар все еще находится в моем кошельке. (Я также не хочу использовать setApprovalForAll(), чтобы одобрить все NFT предметы в этой коллекции в моем адресе для рыночного контракта. Мы предпочитаем утверждать НМТ поштучно).

Задание 1.2: Настройка каталога и проекта

ШАГ 1: Создайте каталоги

Мы разделим наш проект на два подкаталога, chain для проекта hardhat, и webapp для проекта React/Next.js.

--nftmarket
  --chain
  --webapp
Войдите в полноэкранный режим Выйдите из полноэкранного режима

ШАГ 2: Проект Hardhat

В подкаталоге chain установите среду разработки hardhat и библиотеку Solidity @openzeppelin/contracts. Затем инициализируем пустой проект hardhat.

yarn init -y
yarn add hardhat
yarn add @openzeppelin/contracts
yarn hardhat
Вход в полноэкранный режим Выход из полноэкранного режима

В качестве альтернативы вы можете загрузить стартовый проект цепочки hardhat из репозитория github. В директории nftmarket запустите:

git clone git@github.com:fjun99/chain-tutorial-hardhat-starter.git chain
Войти в полноэкранный режим Выйти из полноэкранного режима

ШАГ 3: Проект вебаппа на React/Next.js

Вы можете загрузить пустой webapp scaffold:

git clone https://github.com/fjun99/webapp-tutorial-scaffold.git webapp
Войти в полноэкранный режим Выйти из полноэкранного режима

Вы также можете загрузить кодовую базу webapp этого учебника:

git clone git@github.com:fjun99/web3app-tutrial-using-web3react.git webapp
cd webapp
git checkout nftmarket
Войти в полноэкранный режим Выйти из полноэкранного режима

Задание 2: Смарт-контракт NFT по сбору средств

Задача 2.1: написать смарт-контракт NFT

Мы пишем смарт-контракт NFT ERC721, наследуя реализацию OpenZeppelin ERC721. Здесь мы добавляем три функции:

  • tokenId: автоматический инкремент tokenId, начиная с 1
  • функция mintTo(адрес _to): каждый может вызвать ее, чтобы майнить NFT
  • функция tokenURI() для реализации URI токена и SVG изображений на цепочке
// 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));
    }    
}

Вход в полноэкранный режим Выйти из полноэкранного режима

Мы также добавим скрипт развертывания scripts/deploy_BadgeToken.ts, который развернет этот контракт NFT с именем:BadgeToken и символом:BADGE:

  const token = await BadgeToken.deploy('BadgeToken','BADGE')
Вход в полноэкранный режим Выход из полноэкранного режима

Задача 2.2: Понять tokenURI()

Объясним реализацию функции ERC721 tokenURI() .

tokenURI() — это функция метаданных для стандарта ERC721. Документация OpenZeppelin :

tokenURI(uint256 tokenId) → строка
Возвращает унифицированный идентификатор ресурса (URI) для токена tokenId.

Обычно tokenURI() возвращает URI. Вы можете получить результирующий URI для каждого токена, соединив baseURI и tokenId.

В нашей tokenURI() мы возвращаем URI в виде объекта с кодировкой base64:

Сначала мы создаем объект. Изображение svg в объекте также кодируется base64.

{
"name":"Badge #1",
"description":"Badge NFT with on-chain SVG image."
"image":"data:image/svg+xml;base64,[svg base64 encoded]"
}
Вход в полноэкранный режим Выход из полноэкранного режима

Затем мы возвращаем объект в кодировке base64.

data:application/json;base64,(object base64 encoded)
Вход в полноэкранный режим Выход из полноэкранного режима

Webapp может получить URI, вызвав tokenURI(tokenId), и декодировать его, чтобы получить название, описание и SVG-изображение.

SVG-изображение адаптировано из проекта LOOT. Оно очень простое. Оно отображает идентификатор токена на изображении.

<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>
Вход в полноэкранный режим Выход из полноэкранного режима

Задача 2.3: Юнит-тест для контракта ERC721

Давайте напишем скрипт модульного тестирования для этого контракта:

// 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)
  })  
})
Войти в полноэкранный режим Выйти из полноэкранного режима

Запустите модульный тест:

yarn hardhat test test/BadgeToken.test.ts
Войти в полноэкранный режим Выйти из полноэкранного режима

Результаты:

  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)
Войти в полноэкранный режим Выход из полноэкранного режима

Мы также можем распечатать tokenURI, полученный в модульном тесте, для проверки:

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>
Войти в полноэкранный режим Выход из полноэкранного режима

Задача 3: Веб-страница для отображения элемента NFT

Задача 3.1: Настройте проект webapp, используя Web3-React & Chakra UI.

Мы будем использовать web3 connection framework Web3-React для выполнения нашей работы. Стеком веб-приложений являются:

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

_app.tsx:

// 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

Вход в полноэкранный режим Выход из полноэкранного режима

Мы будем использовать компонент ConnectMetamask в нашем предыдущем уроке: Tutorial: build DApp with Web3-React and SWRTutorial: build DApp with Web3-React and SWR.

Задание 3.2: Напишите компонент для отображения времени NFT

В этом компоненте мы также используем SWR, как и в предыдущем уроке. Фетчер SWR находится в 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>
)
}
Вход в полноэкранный режим Выход из полноэкранного режима

Некоторые пояснения:

  • При подключении к кошельку MetaMask этот компонент запрашивает tokenURI(tokenId), чтобы получить название, описание и svg изображение элемента NFT.

Давайте напишем страницу для отображения элемента NFT.

// 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
Вход в полноэкранный режим Выход из полноэкранного режима

Задание 3.3: Запустить проект webapp

ШАГ 1: Запустите автономный локальный тестнет

В другом терминале запустите в директории chain/:

yarn hardhat node
Войти в полноэкранный режим Выйти из полноэкранного режима

ШАГ 2: Разверните BadgeToken (ERC721) в локальной тестовой сети

yarn hardhat run scripts/deploy_BadgeToken.ts --network localhost
Войти в полноэкранный режим Выйти из полноэкранного режима

Результат:

Deploying BadgeToken ERC721 token...
BadgeToken deployed to: 0x5FbDB2315678afecb367f032d93F642f64180aa3
Войти в полноэкранный режим Выход из полноэкранного режима

ШАГ 3: Создайте BadgeToken (tokenId = 1) в консоли hardhat

Запустите консоль hardhat connect to local testenet

yarn hardhat console --network localhost
Войти в полноэкранный режим Выйти из полноэкранного режима

В консоли:

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...'
Войти в полноэкранный режим Выйти из полноэкранного режима

Теперь у нас есть пункт NFT. Мы отобразим его на веб-странице.

ШАГ 3: подготовьте вашу MetaMask

Убедитесь, что в вашей MetaMask есть локальный testnet wich RPC URL http://localhost:8545 и chain id 31337.

ШАГ 4: запустите webapp

В webapp/ запустите:

yarn dev
Войти в полноэкранный режим Выйти из полноэкранного режима

В браузере chrome перейдите на страницу: http://localhost:3000/samplenft.

Подключите MetaMask, элемент NFT будет отображен на странице. (Обратите внимание, что изображение загружается медленно. Дождитесь завершения загрузки.

Мы видим, что NFT «Badge #1» с tokenId 1 отображается правильно.


Задача 4: Смарт-контракт NFT marketplace

Задача 4.1: Структура данных контракта

Мы адаптировали смарт-контракт Market.sol из учебника Надера Дабита (V1) для написания нашего маркетплейса. Большое спасибо. Но вы должны заметить, что мы вносим много изменений в этот контракт.

Мы определяем struct MarketItem:

  struct MarketItem {
    uint id;
    address nftContract;
    uint256 tokenId;
    address payable seller;
    address payable buyer;
    uint256 price;
    State state;
  }
Войти в полноэкранный режим Выйти из полноэкранного режима

Каждый элемент рынка может находиться в одном из трех состояний:

  enum State { Created, Release, Inactive }
Войти в полноэкранный режим Выход из полноэкранного режима

Обратите внимание, что мы не можем полагаться на состояние Created. Если продавец передаст предмет NFT другим или продавец снимет одобрение с предмета NFT, состояние все еще будет Created, указывая, что другие могут покупать на рынке. На самом деле другие не могут его купить.

Все предметы хранятся в mapping:

  mapping(uint256 => MarketItem) private marketItems;
Вход в полноэкранный режим Выход из полноэкранного режима

У рынка есть владелец, который является организатором контракта. Плата за листинг будет поступать владельцу рынка, когда на рынке будет продан выставленный на продажу предмет NFT.

В будущем мы можем добавить функциональные возможности для передачи прав собственности на другой адрес или кошелек с мульти-сигмой. Чтобы сделать учебник простым, мы пропустим эти функции.

Этот рынок имеет статическую плату за листинг:

  uint256 public listingFee = 0.025 ether;
  function getListingFee() public view returns (uint256) 
Войти в полноэкранный режим Выход из полноэкранного режима

Задание 4.2: методы работы с рынком

Рынок имеет две категории методов:

Основные функции:

  function createMarketItem(address nftContract,uint256 tokenId,uint256 price) payable 
  function deleteMarketItem(uint256 itemId) public
  function createMarketSale(address nftContract,uint256 id) public payable
Вход в полноэкранный режим Выход из полноэкранного режима

Функции запроса:

  function fetchActiveItems() public view returns (MarketItem[] memory) 
  function fetchMyPurchasedItems() public view returns (MarketItem[] memory)
  function fetchMyCreatedItems() public view returns (MarketItem[] memory) 
Войти в полноэкранный режим Выход из полноэкранного режима

Полный смарт-контракт выглядит следующим образом:

// 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;
    }
  }

}

Вход в полноэкранный режим Выход из полноэкранного режима

Этот контракт NFTMarket может работать, но он не очень хорош. Необходимо сделать как минимум две работы:

  • Мы должны добавить пагинацию в функции запроса. Если на рынке тысячи товаров, функция запроса не может работать хорошо.

  • Когда мы пытаемся проверить, передал ли продавец предмет NFT другим или удалил одобрение с рынка, мы вызываем nft.getApproved тысячи раз. Это плохая практика. Мы должны попытаться найти решение.

Мы также можем обнаружить, что позволять веб-приложению запрашивать данные непосредственно из смарт-контракта — не лучший вариант. Необходим слой индексирования данных. Протокол Graph Protocol и подграф могут выполнить эту работу. Объяснение того, как использовать subgraph, вы можете найти в учебнике Dabit по рынку NFT.


Замечание о delegatecall

Когда я создавал смарт-контракт NFTMarketplace, я исследовал неправильный путь в течение одного дня и многому научился. Вот что я узнал.

  • Когда продавец размещает НМТ на торговой площадке, он дает разрешение рыночному контракту approve(marketaddress) на передачу НМТ от продавца к покупателю, вызывая transferFrom(). Я хотел бы не использовать setApprovalForAll(operator, approved), который даст одобрение рыночного контракта для всех моих НМТ в одной коллекции.

  • Продавец может захотеть удалить (исключить из списка) НМТ с рынка, поэтому мы добавляем функцию deleteMarketItem(itemId).

  • Здесь начинается неправильный путь. Я пытаюсь удалить одобрение для продавца в рыночном контракте.

    • Вызов nft.approve(address(0),tokenId) вернется назад. Рыночный контракт не является владельцем этого NFT и не одобрен для всех как оператор.
    • Возможно, мы можем использовать delegatecall, который будет вызван с использованием оригинального msg.sender (продавец). Продавец является владельцем.
    • Я всегда получаю «Error: VM Exception while processing transaction: reverted with reason string ‘ERC721: owner query for nonexistent token'». Что происходит?
    • Когда я пытаюсь делегировать вызов других функций, таких как name(), результат не правильный.
    • Копать, копать и копать. Наконец, я обнаружил, что неправильно понял delegatecall. Delegatecall использует хранилище вызывающей стороны (контракт market) и не использует хранилище вызываемой стороны (контракт nft). Solidity Docs пишет: «Хранилище, текущий адрес и баланс по-прежнему относятся к вызывающему контракту, только код берется из вызываемого адреса. «
    • Поэтому мы не можем делегировать вызов nft.approve() для удаления одобрения в рыночном контракте. Мы не можем получить доступ к исходным данным в контракте NFT через delegatecall.

Фрагмент кода delegatecall (который является неправильным):

    bytes memory returndata = Address.functionDelegateCall(
      item.nftContract, 
      abi.encodeWithSignature("approve(address,uint256)",address(0),1)
    );
    Address.verifyCallResult(true, returndata, "approve revert");
Войти в полноэкранный режим Выход из полноэкранного режима
  • Но это еще не конец. В конце концов я обнаружил, что не стоит пытаться убрать одобрение в рыночном контракте. Логика неправильная.

    • Продавец вызывает рыночный контракт deleteMarketItem для удаления рыночного товара.
    • Продавец не просит рыночный контракт вызвать nft-контракт «approve()» для удаления одобрения. (Существует ERC20Permit, но в ERC721 разрешения пока нет).
    • Дизайн блокчейна не позволяет контракту делать это.
  • Если продавец хочет это сделать, он должен сделать это сам, вызвав approve() напрямую. Именно это мы делаем в модульном тесте await nft.approve(ethers.constants.AddressZero,1).

Opensea предлагает использовать isApprovedForAll в своем руководстве (пример кода):

    /**
     * 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);
    }

Вход в полноэкранный режим Выход из полноэкранного режима

Механизм «одобрить для всех» довольно сложен, и вы можете обратиться к контракту opensea proxy для получения дополнительной информации.


Задача 4.3: Юнит-тест для NFTMarketplace (основная функция)

Мы добавим два скрипта модульного тестирования для NFTMarketplace:

  • один для основных функций
  • один для функций запроса/выборки

Скрипт модульного тестирования для основных функций:

// 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))
  })
})

Вход в полноэкранный режим Выход из полноэкранного режима

Запуск:

yarn hardhat test test/NFTMarketplace.test.ts
Войти в полноэкранный режим Выйти из полноэкранного режима

Результаты:

  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)
Войти в полноэкранный режим Выход из полноэкранного режима

Задача 4.4: Модульное тестирование для NFTMarketplace (функция запроса)

Сценарий модульного тестирования для функции запроса:

// 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

  })    

})

Вход в полноэкранный режим Выход из полноэкранного режима

Запуск:

yarn hardhat test test/NFTMarketplaceFetch.test.ts
Войти в полноэкранный режим Выйти из полноэкранного режима

Результаты:

  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)
Войти в полноэкранный режим Выход из полноэкранного режима

Задание 4.5: Вспомогательный скрипт playMarket.ts для разработки смарт-контракта

Напишем скрипт src/playMarket.ts. В процессе разработки и отладки я запускаю этот скрипт снова и снова. Он помогает мне понять, может ли рыночный контракт работать так, как он задуман.

// 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
})

Вход в полноэкранный режим Выход из полноэкранного режима

Что мы делаем в этом скрипте:

  • развертываем BadgeToken NFT и NFTMarketplace
  • чеканить 3 предмета NFT на адрес0
  • утвердить 3 элемента NFT в рыночном контракте
  • перечислить 3 предмета NFT на NFTMarketplace
  • передайте Бейдж #3 другому
  • перечисленные предметы должны быть #1,#2
  • адрес1(счет1) купить значок №1
  • адрес1 приобретенный товар должен быть №1
  • распечатать tokenId, имя, svg для проверки

Выполнить:

yarn hardhat run src/playMarket.ts
Войти в полноэкранный режим Выйти из полноэкранного режима

Результат:

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.
Войти в полноэкранный режим Выход из полноэкранного режима

Задание 4.6: Скрипты для подготовки к работе с webapp

Нам необходимо подготовить данные для webapp:

  • 1-6 по Счету#0, 1- Счет1, 2- Счет#2
  • 7-9 по счету#1, 7,8 — счет#0
  • На рынке: 3,4,5,9 (6 делистинг по счету#0)
  • Счет#0: покупка 7,8, список: 1-5 (6 исключены из списка)
  • Счет#1:покупка 1, список:7-9
  • Счет#2:Купить 2, Список:н/а
// 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
})

Вход в полноэкранный режим Выход из полноэкранного режима

Запустите автономный локальный тестнет на другом терминале:

yarn hardhat node
Войти в полноэкранный режим Выйти из полноэкранного режима

Запустить:

yarn hardhat run src/prepare.ts --network localhost
Войти в полноэкранный режим Выйти из полноэкранного режима

Результаты:

========   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.
Войти в полноэкранный режим Выйти из полноэкранного режима

Задание 5: Webapp для NFTMarketplace

Задача 5.1: добавить компонент ReadNFTMarket.

В настоящее время мы запрашиваем рыночный контракт напрямую вместо использования SWR в этом фрагменте кода.

// 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>

  )
}
Вход в полноэкранный режим Выход из полноэкранного режима

Задача 5.2: добавить ReadNFTMarket в индекс

Мы добавляем три ReadNFTMarket в index.tsx:

  • один для всех товаров на рынке
  • один для моих купленных товаров
  • один для моих созданных товаров

Задача 5.3: Запуск DApp

ШАГ 1: запустите новую локальную тестовую сеть

В другом терминале запустите chain/.

yarn hardhat node
Войдите в полноэкранный режим Выйти из полноэкранного режима

ШАГ 2: подготовить данные для webapp
Запустите в chain/

yarn hardhat run src/prepare.ts --network localhost
Войти в полноэкранный режим Выйти из полноэкранного режима

ШАГ 3: запустить webapp

Запустить в webapp/

yarn dev
Войти в полноэкранный режим Выйти из полноэкранного режима

ШАГ 4: браузер http://localhost:3000/ и подключите MetaMask

Установите в качестве мнемоники MetaMask предустановленную реф-ссылку Hardhat и добавьте в нее аккаунты:

test test test test test test 
test test test test test junk
Войти в полноэкранный режим Выйти из полноэкранного режима

ШАГ 5: купите значок #9 как аккаунт#0

ШАГ 6: переключитесь на аккаунт №1 в MetaMask, купите значок №3.

Теперь у вас есть рынок NFT. Поздравляем.


Вы можете продолжить его развертывание в публичном testnet(ropsten), ethereum mainnet, sidechain(BSC/Polygon), Layer2(Arbitrum/Optimism).


Необязательное задание 6: Развертывание на Polygon и запрос с помощью API Alchemy NFT

Задание 6.1 Развертывание в Polygon

В этом дополнительном задании я разверну контракт NFT и контракт NFTMarketplace в Polygon mainnet, поскольку плата за газ является нормальной. Вы также можете выбрать развертывание в Ethereum testnet (Goerli), Polygon testnet (Mumbai) или Layer 2 testnet (например, Arbitrum Goerli).

ШАГ 1. Отредактируйте .env с URL Alchemy с ключом, вашим приватным ключом для тестирования, ключом API Polygonscan. Вам может понадобиться добавить полигон в ваш hardhat.config.ts.

POLYGONSCAN_API_KEY=ABC123ABC123ABC123ABC123ABC123ABC1
POLYGON_URL=https://polygon-mainnet.g.alchemy.com/v2/<YOUR ALCHEMY KEY>
POLYGON_PRIVATE_KEY=0xabc123abc123abc123abc123abc123abc123abc123abc123abc123abc123abc1
Вход в полноэкранный режим Выход из полноэкранного режима

ШАГ 2. Разверните контракт NFT и проверьте его на сайте polygonscan.com. Выполнить:

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
Войти в полноэкранный режим Выйти из полноэкранного режима

ШАГ 3. Разверните NFTMarketplace и проверьте.

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
Войти в полноэкранный режим Выйти из полноэкранного режима

Задача 6.2 Монетирование NFT и размещение на торговой площадке

ШАГ 4. Монтируйте один NFT (tokenId=1) на свой тестовый аккаунт на https://polygonscan.com/.

Вы можете просмотреть NFT «Badge #1» на opensea: https://opensea.io/assets/matic/0x1fc8b9dc757fd50bfec8bbe103256f176435faee/1.

ШАГ 5. Внесите NFT «Badge #1» в контракт NFTMarketpalce на https://polygonscan.com/.

Сначала вам необходимо утвердить товар NFT «Badge #1» в NFTMarketpalce.

Затем вы вызываете CreateMarketItem().

ШАГ 6. Запустите веб-приложение. После подключения кошелька вы сможете увидеть предмет на рынке.

Примечание: не забудьте отредактировать контракт NFT и адрес контракта NFTMarketpalce в webapp/src/projectsetting.ts.


Задача 6.3 Запрос к NFT с помощью API Alchemy NFT

Теперь мы можем перейти к использованию Alchemy NFT APIs (ссылка на документацию) для запроса данных NFT и отображения их в нашем webapp.

Давайте попробуем это сделать. Для демонстрации мы будем использовать Alchemy SDK.

yarn add alchemy-sdk
Вход в полноэкранный режим Выход из полноэкранного режима

Этот фрагмент кода адаптирован из документации Alchemy NFT APIs(ссылка). Для его выполнения вам потребуется ключ Alchemy API Key.

// 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)
Вход в полноэкранный режим Выйти из полноэкранного режима

Результаты:

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>
Войти в полноэкранный режим Выход из полноэкранного режима

Вот и все. Мы разработали супер упрощенную версию Opensea, включая контракт и webapp. Нам предстоит еще много работы. Возьмем, например, такой пример:

  • Ваша первая версия NFTMarketpalce работает хорошо. Через несколько недель вы обнаруживаете, что вам нужно добавить новую функциональность в NFTMarketplace.

  • Смарт-контракт неизменяем. Развертывание новой версии NFTMarketplace и просьба пользователей перечислить свои NFT в новый контракт — не лучшая идея.

  • Теперь вам нужен обновляемый смарт-контракт (модель прокси-контракта). Вы можете узнать, как разработать прокси-контракт в моем другом руководстве: Tutorial: write upgradeable smart contract (proxy) using OpenZeppelin.


Список уроков:

1. Краткий учебник по Hardhat (3 части)

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

2. Понимание блокчейна с помощью Ethers.js(5 частей)

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

3. Учебник: создайте свой первый DAPP с Remix и Etherscan (7 заданий)

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

4. Учебник: создание DApp с помощью Hardhat, React и Ethers.js (6 заданий)

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

5. Учебник: создание DAPP с помощью Web3-React и SWR (5 заданий)

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

6. Учебник: написать обновляемый смарт-контракт (прокси) с помощью OpenZeppelin(7 задач)

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

7. Учебник: Построение NFT маркетплейса DApp как Opensea(5 задач)

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


Если вы нашли этот учебник полезным, следуйте за мной в Twitter @fjun99

1 комментарий к “Учебник Web3: Создание NFT marketplace DApp наподобие OpenSea”

Оставьте комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *