Web3 教程:构建类似 OpenSea 的 NFT 市场 DApp

2025-06-10

Web3 教程:构建类似 OpenSea 的 NFT 市场 DApp

使用 Solidity 和 JavaScript/React 构建类似 Opensea 的 NFT 市场 DApp,或许会成为你 Web3 开发之旅中的重要一步。让我们一起行动吧!

在我之前的教程中,您可能已经了解:

  • 如何编写智能合约并使用安全帽在本地区块链测试网上运行它。

  • 如何构建全栈 DApp,这意味着处理 DApp 的所有三个部分:智能合约、Web 应用程序和用户钱包。

  • 如何进行单元测试、部署到公共测试网络、在区块浏览器 Etherscan 上验证智能合约。

现在,您可以开始编写一个功能齐全的智能合约:一个数字物品的交易市场。NFT 集合中的 NFT 物品就是在这里交易的数字物品。

目录


Nader Dabit 撰写了两个版本的《如何构建全栈 NFT 市场 - V2 (2022)》。受他的想法启发,并基于他的智能合约代码库,我编写了更多代码,并为您撰写了本教程。

你可能已经看过我之前的教程了。如果没有,我建议你阅读接下来的两篇,因为有些策略我已经写好了,我不会再解释了。

让我们开始建造吧。


任务 1

任务 1:构建内容和项目设置

任务 1.1:我们构建什么 - 三个部分

  • 一个 NFT 收藏智能合约和一个用于展示 NFT 物品的简单网页。我们将使用链上 SVG 作为 NFT 物品的图像。我们需要这个 NFT 收藏示例,以便在市场合约和店面中使用。

  • 一个 NFT 市场智能合约,用户可以在其中上架和购买 NFT 商品。卖家也可以从市场中下架自己的 NFT。该市场合约还为 Web 应用提供了查询市场数据的函数。我们将尽可能地用单元测试覆盖该智能合约。

  • 使用 React/Web3-React/SWR 构建的NFT 市场店面。(为了简化操作,我们只在单页 Web 应用中构建了店面所需的必要组件。例如,我们不会提供用于在 Web 应用中上架 NFT 的 UI 组件。)

该项目的关键部分是市场智能合约(NFTMarketplace),具有数据存储、核心功能和查询功能。

核心功能:

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

查询功能:

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

卖家可以使用智能合约来:

  • 批准 NFT 市场合约
  • 创建带有上市费用的市场商品
  • ...(等待买家购买 NFT)...
  • 收到买家支付的价格价值

当买家在市场上购买时,市场合约会促进购买过程:

  • 买家通过支付价格购买 NFT
  • 市场合约完成购买流程:
    • 将价格价值转移给卖方
    • 将 NFT 从卖方转移到买方
    • 将上市费用转给市场所有者
    • 将市场商品状态从 更改CreatedRelease

本教程的 GitHub 仓库:

虽然我从 Dabit 的 NFT 市场教程中学到了很多东西,但我们要构建的内容与他的教程相比有 3 个主要区别:

  • Dabit 的 NFT 是一种传统的 NFT,它将图像存储在 IPFS 上,而我们的 NFT 将 SVG 图像存储在链上(仅存储数据,不存储图像)。我们使用这种方式是为了简化教程,因为我们无需搭建服务器来提供 NFT 代币 URI(Restful JSON API),也无需处理服务器或 IPFS 上的图像存储。

  • 在 Dabit 教程的第一个版本中,他将 NFT ERC721 代币智能合约和市场智能合约分开。在第二个版本中,他选择在一个智能合约中构建一个具有市场功能的 NFT ERC721 代币智能合约。我们在这里选择将它们分开,因为我们想要构建一个通用的 NFT 市场。

  • 在 Dabit 的教程中,当卖家将 NFT 商品上架到市场时,他会将 NFT 商品转移到市场合约,然后等待其出售。作为区块链和 Web3.0 用户,我不喜欢这种模式。我希望只将 NFT 商品批准到市场合约。这样,在商品出售之前,它仍然在我的钱包里。(我也不想将setApprovalForAll()我地址中此集合中的所有 NFT 商品都批准到市场合约。我们选择逐个批准 NFT 商品。)

任务 1.2:目录和项目设置

步骤 1:创建目录

我们将把项目分成两个子目录,chain一个用于 hardhat 项目,另一个webapp用于 React/Next.js 项目。

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

步骤 2:安全帽项目

chain子目录中,安装hardhat开发环境和@openzeppelin/contractsSolidity 库。然后我们初始化一个空的 Hardhat 项目。

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

或者,您可以从github repo下载安全帽链启动项目。在您的nftmarket目录中,运行:

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

步骤 3:React/Next.js webapp 项目

您可以下载一个空的 webapp 脚手架:

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

您还可以下载本教程的 webapp 代码库:

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

任务 2

任务二:NFT收集智能合约

任务 2.1:编写 NFT 智能合约

我们编写了一个继承 OpenZeppelin ERC721 实现的 NFT ERC721 智能合约。我们在这里添加了三个功能:

  • tokenId:tokenId从1开始自动递增
  • 功能mintTo(address _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));
    }    
}

Enter fullscreen mode Exit fullscreen mode

我们还添加了一个部署脚本,该脚本将使用名称:和符号:scripts/deploy_BadgeToken.ts部署此 NFT 合约:BadgeTokenBADGE

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

任务 2.2:理解 tokenURI()

下面我们来解释一下ERC721功能的实现tokenURI()

tokenURI()是 ERC721 标准的元数据函数。OpenZeppelin 文档:

tokenURI(uint256 tokenId) → string
返回 tokenId 令牌的统一资源标识符 (URI)。

通常tokenURI()返回一个 URI。您可以通过连接 baseURI 和 tokenId 来获取每个 token 的最终 URI。

在我们的中tokenURI(),我们返回一个使用 base64 编码的对象形式的 URI:

首先我们构造一个对象,对象中的 svg 图片也是经过 base64 编码的。

{
"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

然后我们返回 base64 编码的对象。

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

Webapp 可以通过调用 获取URI tokenURI(tokenId),并对其进行解码以获取名称、描述和SVG图像。

该 SVG 图像改编自 LOOT 项目。它非常简单。它在图像中显示 tokenId。

<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

任务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)
  })  
})
Enter fullscreen mode Exit fullscreen mode

运行单元测试:

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

结果:

  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

我们还可以将单元测试中得到的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>
Enter fullscreen mode Exit fullscreen mode

任务 3

任务三:展示NFT物品的网页

任务 3.1:使用Web3-React&设置 webapp 项目Chakra UI

我们将使用 Web3 连接框架Web3-React来完成我们的工作。Web 应用堆栈如下:

  • 反应
  • Next.js
  • Chakra 用户界面
  • Web3-React
  • Ethers.js
  • 驻波比

_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

Enter fullscreen mode Exit fullscreen mode

我们将使用ConnectMetamask我们上一个教程中的组件:教程:使用 Web3-React 和 SWR 构建 DApp教程:使用 Web3-React 和 SWR 构建 DApp

任务 3.2:编写一个组件来显示 NFT 时间

在这个组件中,我们仍然SWR像上一个教程一样使用。fetcherSWR在 中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

一些解释:

  • 当连接到 MetaMask 钱包时,该组件查询 tokenURI(tokenId)以获取 NFT 项目的名称、描述和 svg 图像。

让我们编写一个页面来显示 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
Enter fullscreen mode Exit fullscreen mode

任务 3.3:运行 webapp 项目

步骤 1:运行独立的本地测试网

在另一个终端中,在chain/目录中运行:

yarn hardhat node
Enter fullscreen mode Exit fullscreen mode

步骤 2:将 BadgeToken (ERC721) 部署到本地测试网

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

结果:

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

第 3 步:在安全帽控制台中铸造一个 BadgeToken (tokenId = 1)

运行安全帽控制台连接到本地测试网

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

在控制台中:

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

现在我们有了 NFT 物品。我们将在网页上显示它。

步骤 3:准备你的 MetaMask

确保您的 MetaMask 具有本地测试网络,其中包括 RPC URLhttp://localhost:8545和链 ID 31337

步骤 4:运行 webapp

在 中webapp/运行:

yarn dev
Enter fullscreen mode Exit fullscreen mode

在 chrome 浏览器中,转到页面:http://localhost:3000/samplenft

连接 MetaMask,NFT 道具将会显示在页面上。(请注意,图片为延迟加载,请等待加载完成。)

NFT物品

我们可以看到带有 tokenId 的 NFT“Badge #1”1已正确显示。


任务 4

任务 4:NFT 市场智能合约

任务4.1:合约数据结构

我们改编了Market.solNader Dabit 教程(V1)中的智能合约来编写我们的市场。非常感谢。但请注意,我们在此合约中做了很多更改。

我们定义一个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

每个市场商品可以处于以下三种状态之一:

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

请注意,我们不能依赖Created状态。如果卖家将 NFT 物品转让给他人,或卖家取消了对该 NFT 物品的授权,状态仍然会Created显示其他人可以在市场上购买。实际上,其他人无法购买。

所有物品都存储在mapping

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

市场拥有一位所有者,即合约部署者。当已上架的NFT商品在市场上售出时,上架费将支付给市场所有者。

未来我们可能会添加将所有权转移到其他地址或多重签名钱包的功能。为了简化教程,我们跳过了这些功能。

该市场有一个固定的上市费用:

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

任务 4.2:市场方法

市场有两类方法:

核心功能:

  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

查询功能:

  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

完整的智能合约如下:

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

这个 NFTMarket 合约可以发挥作用,但效果并不好。至少有两件事要做:

  • 我们应该在查询功能中添加分页功能。如果市场上有成千上万的商品,查询功能就无法正常工作。

  • 当我们试图核实卖家是否已经将NFT物品转让给他人,或者是否已取消其在市场上的批准时,我们会打nft.getApproved上千通电话。这种做法很糟糕。我们应该想办法解决。

我们可能还会发现,让 Web 应用直接从智能合约查询数据并不是一个好的设计。需要一个数据索引层。Graph协议和子图可以完成这项工作。你可以在Dabit 的 NFT 市场教程中找到如何使用子图的说明


思考笔记delegatecall

我在构建 NFTMarketplace 智能合约的过程中,摸索了大约一天的时间,也学到了很多东西。以下是我学到的东西。

  • 当卖家将 NFT 上架到市场时,他需要approve(marketaddress)通过调用 来授权市场合约批准将 NFT 从卖家转移到买家transferFrom()。我希望不使用 ,setApprovalForAll(operator, approved)这样可以将我所有的 NFT 集中到一个集合中,并授权市场合约批准。

  • 卖家可能想从市场上删除(下架)一个 NFT,所以我们添加了一个功能deleteMarketItem(itemId)

  • 错误的道路从这里开始。我正在尝试取消市场合同中对卖方的批准。

    • 调用nft.approve(address(0),tokenId)将被撤销。市场合约并非此 NFT 的所有者,也未被所有人批准作为运营商。
    • 也许我们可以用delegatecall原件(卖方)来称呼它msg.sender。卖方就是所有者。
    • 我总是收到“错误:虚拟机处理交易时发生异常:已还原,原因字符串为‘ERC721:所有者查询不存在的代币’”。这是怎么回事?
    • 当我尝试委托调用其他函数(例如name())时,结果不正确。
    • 挖啊挖啊挖。终于,我发现我理解错了delegatecall。Delegatecall 使用的是调用方(市场合约)的存储,而没有使用被调用方(NFT 合约)的存储。Solidity文档写道:“存储、当前地址和余额仍然指向调用方合约,只有代码取自被调用方的地址。”
    • 因此,我们无法通过委托调用nft.approve()来移除市场合约中的批准。我们也无法通过委托调用访问 NFT 合约中的原始数据。

委托调用

delegatecall 代码片段(错误的):

    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
  • 但这还没完。我最终发现,我不应该试图取消市场合约中的批准。这个逻辑是错误的。

    • 卖方调用市场合同deleteMarketItem来移除市场物品。
    • 卖方无需要求市场合约调用 nft 合约的“approve()”来撤销批准。(虽然有ERC20Permit,但 ERC721 中尚无许可证。)
    • 区块链的设计不允许合约这样做。
  • 如果卖家想这样做,他应该自己直接打电话来做approve()。这就是我们在单元测试中所做的await nft.approve(ethers.constants.AddressZero,1)

isApprovedForAllOpensea在其教程(示例代码)中建议使用

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

“全部批准”机制比较复杂,可以参考opensea代理合约了解更多信息。


任务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))
  })
})

Enter fullscreen mode Exit fullscreen mode

跑步:

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

结果:

  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

任务 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

  })    

})

Enter fullscreen mode Exit fullscreen mode

跑步:

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

结果:

  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

任务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
})

Enter fullscreen mode Exit fullscreen mode

我们在这个脚本中做了什么:

  • 部署 BadgeToken NFT 和 NFTMarketplace
  • 铸造 3 个 NFT 物品到 address0
  • 批准 3 个 NFT 物品进入市场合约
  • 将 3 个 NFT 商品添加到 NFTMarketplace
  • 将徽章#3转让给其他人
  • 列出的项目应该是#1,#2
  • address1(account1) 购买徽章 #1
  • address1 购买的物品应该是#1
  • 打印 tokenId、name、svg 以供检查

跑步:

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

结果:

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

任务 4.6:为 webapp 准备的脚本

我们需要为 Web 应用准备数据:

  • 1-6 对应账户#0,1- 账户1,2- 账户#2
  • 账户#1 为 7-9,账户#0 为 7,8
  • 市场中:3、4、5、9(账户#0 已下架 6 个)
  • 账户#0:买入 7,8,列表:1-5(6 已下架)
  • 账户#1:买1,列表:7-9
  • 账户#2:购买 2,列表: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

在另一个终端中运行独立的本地测试网络:

yarn hardhat node
Enter fullscreen mode Exit fullscreen mode

跑步:

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

结果:

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

任务 5

任务 5:NFTMarketplace 的 Webapp

任务 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>

  )
}
Enter fullscreen mode Exit fullscreen mode

任务 5.2:添加ReadNFTMarket到索引

ReadNFTMarket我们向 index.tsx添加三个:

  • 所有市场商品一应俱全
  • 一个用于存放我购买的物品
  • 一个用于我创建的项目

去中心化应用

任务 5.3:运行 DApp

第 1 步:运行新的本地测试网

在另一个终端中运行chain/

yarn hardhat node
Enter fullscreen mode Exit fullscreen mode


STEP 2:准备webapp运行的数据chain/

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

步骤3:运行webapp

跑入webapp/

yarn dev
Enter fullscreen mode Exit fullscreen mode

步骤 4:浏览http://localhost:3000/并连接 MetaMask

将 MetaMask 的助记符设置为 Hardhat 预定义的参考链接,并在其中添加帐户:

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

步骤 5:以账户#0 购买徽章#9

步骤 6:切换到 MetaMask 中的帐户#1,购买徽章#3

现在您拥有了一个 NFT 市场。恭喜!


您可以继续将其部署到公共测试网(ropsten)、以太坊主网、侧链(BSC/Polygon)、Layer2(Arbitrum/Optimism)。


任务 1

可选任务 6:部署到 Polygon 并使用 Alchemy NFT API 进行查询

任务 6.1 部署到多边形

在这个可选任务中,我将把 NFT 合约和 NFTMarketplace 合约部署到 Polygon 主网,因为 Gas 费用可以接受。你也可以选择部署到以太坊测试网(Goerli)、Polygon 测试网(孟买)或 Layer 2 测试网(例如 Arbitrum Goerli)。

步骤 1..env使用 Alchemy URL 编辑密钥、用于测试的私钥以及 Polygonscan API 密钥。您可能需要在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

步骤 2. 部署 NFT 合约并在polyscan.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
Enter fullscreen mode Exit fullscreen mode

步骤 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
Enter fullscreen mode Exit fullscreen mode

任务 6.2 铸造 NFT 并上架市场

步骤 4. 在https://polygonscan.com/上为您的测试帐户铸造一个 NFT(tokenId=1)

您可以在 opensea 上查看 NFT“徽章 #1”:https ://opensea.io/assets/matic/0x1fc8b9dc757fd50bfec8bbe103256f176435faee/1

步骤 5. 将您的 NFT 物品“徽章 #1”添加到https://polygonscan.com/上的 NFTMarketpalce 合约中

首先您需要将 NFT 物品“徽章 #1”批准到 NFTMarketpalce。

然后你打电话CreateMarketItem()

步骤6. 运行网页应用。连接钱包后,即可在市场中看到该商品。

注意:记得在 中编辑 NFT 合约和 NFTMarketpalce 合约地址webapp/src/projectsetting.ts

部署并铸造


任务 6.3 使用 Alchemy NFT API 查询 NFT

现在,我们可以切换到使用 Alchemy NFT API(文档链接)来查询 NFT 数据并将其显示在我们的 Web 应用程序中。

我们来尝试一下。我们将Alchemy SDK在这里进行演示。

yarn add alchemy-sdk
Enter fullscreen mode Exit fullscreen mode

该代码片段改编自 Alchemy NFT API 文档(链接)。您需要一个 Alchemy API 密钥才能运行它。

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

结果:

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

就是这样。我们已经开发了一个超级简化版的 Opensea,包括合约和 WebApp。还有很多工作要做。举个例子:

  • 你的第一个版本的 NFTMarketpalce 运行良好。几周后,你发现需要为 NFTMarketplace 添加新功能。

  • 智能合约是不可变的。部署新版本的 NFTMarketplace 并要求用户将其 NFT 上架到新合约中并不是一个好主意。

  • 现在你需要一个可升级的智能合约(代理合约模式)。你可以在我的另一个教程中学习如何开发代理合约:教程:使用 OpenZeppelin 编写可升级的智能合约(代理)


教程列表:

1. 简明安全帽教程(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. 教程:使用 Remix 和 Etherscan 构建您的第一个 DAPP(7 个任务)

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

4. 教程:使用 Hardhat、React 和 Ethers.js 构建 DApp(6 个任务)

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

5. 教程:使用 Web3-React 和 SWR 构建 DAPP(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. 教程:构建类似 Opensea 的 NFT 市场 DApp(5 个任务)

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


如果您觉得本教程有用,请在 Twitter 上关注我@fjun99

鏂囩珷鏉ユ簮锛�https://dev.to/yakult/tutorial-build-a-nft-marketplace-dapp-like-opensea-3ng9
PREV
理解 JavaScript 闭包
NEXT
如何使用Web3-React开发DApp