译文出自:登链翻译计划[1] 译者:翻译小组[2] 校对:Tiny 熊[3]
使用 Solidity 和 Web3-React 构建一个像 Opensea 一样的 NFT 市场 DApp 是你开启 web3 之旅的一个好步骤。我们来学习编写一个具有完整功能的智能合约实现一个数字藏品的市场。一个集合的 NFT 是这里交易的数字物品。
Nader Dabit 写了两个版本的How to Build a Full Stack NFT Marketplace - V2 (2022)[4]。受他的想法启发,基于他的智能合约代码库,我编写了这个教程。
你可以阅读我以前的教程,并在之后进行练习。如果没有,我建议你在开始之前阅读以下两篇,因为我不会解释那里已经解释的一些技术。
让我们开始构建吧。
项目的关键部分创建有数据存储、买卖核心功能和查询功能的市场智能合约(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)
卖方可以使用智能合约来:
当买方在市场上购买时,市场合约则促进购买进行:
本教程的 GitHub 仓库:
setApprovalForAll()
来授权我地址中的所有 NFT 到市场合约。我们选择以一个一个的方式来授权)。第 1 步:创建目录
我们将把项目分成两个子目录,chain
用于 hardhat 项目,webapp
用于 React/Next.js 项目。
--nftmarket
--chain
--webapp
第 2 步:创建 Hardhat 项目
在chain
子目录下,安装hardhat
开发环境和@openzeppelin/contracts
solidity 库。然后我们启动一个空的 hardhat 项目:
yarn init -y
yarn add hardhat
yarn add @openzeppelin/contracts
yarn hardhat
或者,你可以从github repo[5]下载 hardhart 的启动项目,在你的nftmarket
目录下:
git clone git@github.com:fjun99/chain-tutorial-hardhat-starter.git chain
cd chain
yarn install
第 3 步:创建 React/Next.js webapp 项目
你可以下载一个空的 webapp 脚手架
git clone https://github.com/fjun99/webapp-tutorial-scaffold.git webapp
cd webapp
yarn install
yarn dev
你也可以下载本教程的 webapp 代码库:
git clone git@github.com:fjun99/web3app-tutrial-using-web3react.git webapp
cd webapp
git checkout nftmarket
yarn install
我们编写一个 NFT ERC721 智能合约,继承 OpenZeppelin 的 ERC721 实现。我们在这里添加三个功能:
mintTo(address _to)
每个人都可以铸币// 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));
}
}
我们还添加了一个部署 srcipt scripts/deploy_BadgeToken.ts
,使用名字:BadgeToken 和符号:BADGE 来部署它。
const token = await BadgeToken.deploy('BadgeToken','BADGE')
让我们解释一下 ERC721 tokenURI()函数的实现:
tokenURI()是 ERC721 标准的一个元数据函数,在 OpenZeppelin 文档中 :
tokenURI(uint256 tokenId) → string 返回 tokenId 代币的统一资源标识符(URI)。
通常 tokenURI 会返回一个 URI。我们可以通过连接 baseURI 和 tokenId 来获得每个 token 的 URI 结果。
在我们的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 可以通过调用tokenURI(tokenId)
获得 URI,并解码以获得名称、描述和 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>
让我们为这个合约写一个单元测试脚本:
// test/BadgeToken.test.ts
import { expect } from "chai"
import { Signer } from "ethers"
import { ethers } from "hardhat"
import { BadgeToken } from "../typechain"
const base64 = require( "base-64")
const _name='BadgeToken'
const _symbol='BADGE'
describe("BadgeToken", function () {
let badge:BadgeToken
let account0:Signer,account1:Signer
beforeEach(async function () {
[account0, account1] = await ethers.getSigners()
const BadgeToken = await ethers.getContractFactory("BadgeToken")
badge = await BadgeToken.deploy(_name,_symbol)
})
it("Should has the correct name and symbol ", async function () {
expect(await badge.name()).to.equal(_name)
expect(await badge.symbol()).to.equal(_symbol)
})
it("Should tokenId start from 1 and auto increment", async function () {
const address1=await account1.getAddress()
await badge.mintTo(address1)
expect(await badge.ownerOf(1)).to.equal(address1)
await badge.mintTo(address1)
expect(await badge.ownerOf(2)).to.equal(address1)
expect(await badge.balanceOf(address1)).to.equal(2)
})
it("Should mint a token with event", async function () {
const address1=await account1.getAddress()
await expect(badge.mintTo(address1))
.to.emit(badge, 'Transfer')
.withArgs(ethers.constants.AddressZero,address1, 1)
})
it("Should mint a token with desired tokenURI (log result for inspection)", async function () {
const address1=await account1.getAddress()
await badge.mintTo(address1)
const tokenUri = await badge.tokenURI(1)
// console.log("tokenURI:")
// console.log(tokenUri)
const tokenId = 1
const data = base64.decode(tokenUri.slice(29))
const itemInfo = JSON.parse(data)
expect(itemInfo.name).to.be.equal('Badge #'+String(tokenId))
expect(itemInfo.description).to.be.equal('Badge NFT with on-chain SVG image.')
const svg = base64.decode(itemInfo.image.slice(26))
const idInSVG = svg.slice(256,-13)
expect(idInSVG).to.be.equal(String(tokenId))
// console.log("SVG image:")
// console.log(svg)
})
it("Should mint 10 token with desired tokenURI", async function () {
const address1=await account1.getAddress()
for(let i=1;i<=10;i++){
await badge.mintTo(address1)
const tokenUri = await badge.tokenURI(i)
const data = base64.decode(tokenUri.slice(29))
const itemInfo = JSON.parse(data)
expect(itemInfo.name).to.be.equal('Badge #'+String(i))
expect(itemInfo.description).to.be.equal('Badge NFT with on-chain SVG image.')
const svg = base64.decode(itemInfo.image.slice(26))
const idInSVG = svg.slice(256,-13)
expect(idInSVG).to.be.equal(String(i))
}
expect(await badge.balanceOf(address1)).to.equal(10)
})
})
运行单元测试:
yarn hardhat test test/BadgeToken.test.ts
结果:
BadgeToken
✓ Should has the correct name and symbol
✓ Should tokenId start from 1 and auto increment
✓ Should mint a token with event
✓ Should mint a token with desired tokenURI (log result for inspection) (62ms)
✓ Should mint 10 token with desired tokenURI (346ms)
5 passing (1s)
我们也可以打印单元测试中得到的 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>
Web3-React
和Chakra UI
设置 webapp 项目我们将使用 web3 连接框架Web3-React
来完成我们的工作。网络应用程序栈:
_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
组件。参考教程:用 Web3-React 和 SWR 构建 DApp[6]。
在这个组件中,我们也使用了SWR
,就像我们在教程:用 Web3-React 和 SWR 构建 DApp[7]中做的那样。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>
)
}
进行一些解释:
让我们写一个页面来显示 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
第 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 步:在 hardhat 控制台铸造一个 BadgeToken(tokenId = 1)。
运行 hardhat 控制台连接到本地 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 项目。我们将在网页中显示它。
第三步:准备你的 MetaMask
确保你的 MetaMask 链接本地测试网(RPC URL http://localhost:8545
和链 ID 31337
)。
第 4 步:运行 webapp
在webapp/
中,运行:
yarn dev
在 chrome 浏览器中,进入页面。http://localhost:3000/samplenft
。
连接 MetaMask,NFT 项目将显示在该页面上。(请注意,图片是懒惰的加载。等待加载完成。
我们可以看到,带有 tokenId1
的 NFT Badge #1
被正确显示。
我们从 Nader Dabit 的教程(V1)中改编了Market.sol
智能合约来编写市场合约。但是你应该注意到,我们在这个合约中改变了很多。
我们定义了一个struct MarketItem
:
struct MarketItem {
uint id;
address nftContract;
uint256 tokenId;
address payable seller;
address payable buyer;
uint256 price;
State state;
}
每个 MarketItem 可以是三种状态之一:
enum State { Created, Release, Inactive }
请注意,我们不能依赖Created
状态。如果卖方将 NFT 转让给其他人,或者卖方取消了对 NFT 的授权,该状态仍然是Created
,它表明其他人可以在市场上购买。实际上,其他人无法购买它。
所有 MarketItem 都存储在一个映射
中。
mapping(uint256 => MarketItem) private marketItems;
市场合约有一个所有者,也就是合约部署者。当一个 NFT 在市场上出售时,上架费将被交给市场所有者。
在未来,我们可能会增加功能,将所有权转移到其他地址或多签钱包。为了使教程简单,我们跳过这些功能。
市场合约有一个固定的上架费用。
uint256 public listingFee = 0.025 ether;
function getListingFee() public view returns (uint256)
市场合约你有两类功能:
核心功能:
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 "] memory items = new MarketItem[");
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.getApproved
成千上万次。这是很糟糕的做法。我们应该尝试找出一个解决方案。我们还可能发现,让 webapp 直接从智能合约中查询数据并不是一个好的设计。应该有一个数据索引层。The Graph 协议[8]和 subgraph 可以完成这项工作。你可以在dabit 的 NFT 市场教程[9]中找到关于如何使用 subgraph 的解释。
我们将为 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)
查询功能的单元测试脚本:
// 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)
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
})
我们在这个脚本中做什么?
运行:
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.
我们需要为 webapp 准备模拟数据:
// 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.
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>
)
}
ReadNFTMarket
到索引中我们在 index.tsx 中添加三个ReadNFTMarket
:
第 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
第 5 步:以 0 号账户购买 NFT#9
第 6 步:切换到 MetaMask 的账户#1,购买 NFT #3
现在你有了一个 NFT 市场。恭喜你。
你可以继续把它部署到公共测试网(ropsten)、以太坊主网、侧链(BSC/Polygon)、Layer2(Arbitrum/Optimism)。
如果现在你需要可升级的智能合约(代理合约模式)。你可以参考教程:使用 OpenZeppelin 编写可升级的智能合约[10]。
本翻译由 Duet Protocol[11] 赞助支持。
原文:https://dev.to/yakult/tutorial-build-a-nft-marketplace-dapp-like-opensea-3ng9
[1]
登链翻译计划: https://github.com/lbc-team/Pioneer
[2]
翻译小组: https://learnblockchain.cn/people/412
[3]
Tiny 熊: https://learnblockchain.cn/people/15
[4]
How to Build a Full Stack NFT Marketplace - V2 (2022): https://dev.to/dabit3/building-scalable-full-stack-apps-on-ethereum-with-polygon-2cfb
[5]
github repo: https://github.com/fjun99/chain-tutorial-hardhat-starter
[6]
教程:用Web3-React和SWR构建DApp: https://dev.to/yakult/tutorial-build-dapp-with-web3-react-and-swr-1fb0
[7]
教程:用Web3-React和SWR构建DApp: https://dev.to/yakult/tutorial-build-dapp-with-web3-react-and-swr-1fb0
[8]
The Graph协议: https://thegraph.com/
[9]
dabit的NFT市场教程: https://dev.to/dabit3/building-scalable-full-stack-apps-on-ethereum-with-polygon-2cfb
[10]
使用OpenZeppelin编写可升级的智能合约: https://learnblockchain.cn/article/4282
[11]
Duet Protocol: https://duet.finance/?utm_souce=learnblockchain