简介
Web3 社区对于非同质化带币(NFT)充满了期待。尽管还没有杀手级应用的出现,但是这项技术已经重塑了数字资产所有权,身份体系,创新范式和社区运作方式。
因为 NFT 是可以被买卖交易的数字资产,而 NFT 交易所收集了 NFT 的信息并且撮合了买家和卖家,所以 NFT 交易所是生态中一个必不可少的部分。
这个教程讲解了如何用 Solidity 来搭建 NFT 交易所的“后端”,如何开发承载交易所业务逻辑的智能合约。在代码中,我们会创建一个 NftMarketplace.sol
智能合约和一个兼容 ERC-721(NFT) 标准的代币合约,然后将这个 NFT 展示在我们的交易所上。
你需要有一些编程经验,如果你了解一些基础的 Javascript,就可以完成整个项目开发。当然如果能够熟悉以太坊的术语就更好了,以太坊的相关知识可以通过浏览以太坊官网来了解。
这个交易所将会有以下的基础功能:
- 上架 NFT
- 更新和下架 NFT
- 购买 NFT
- 获取所有的上架 NFT 的信息
- 获取卖家的当前状态
以上功能都会通过交易所智能合约实现。你可以先思考一下上述的功能是什么意思,因为这些功能的代码逻辑,就是它们业务逻辑的实现。比如说,在交易所中上架一个 NFT 的时需要什么数据?需要 Token ID。因为这个交易所可以上架很多不相关的 NFT,同时也需要能够给每一个 token 加上价格。
OK,那在写 NFT 相关合约之前,让我们先设置好项目和开发环境。这个项目的 GitHub Repo 在这里。这个 Repo 会比这个教程的内容更深入,所以你自己可以根据它去实现更多的功能。
项目环境搭建
在这个项目中,我们会用到 yarn,运行 npm install -g yarn
来全局安装它。另外,你需要确定你的机器上有 Node.js, 运行 node –version
来检查它有没有被安装。
除此以外,还会用到 Hardhat 来编译,部署,测试和交互我们的智能合约,Hardhat 是一个以太坊开发环境,相关的知识所以浏览一下 Hardhat 的官网的新手教程。
我将用 <
来指代项目目录,打开命令行,进入到项目目录,打开 IDE(你可以使用任何支持 javascript 的IDE,比如 VSCode)。
在项目目录中,创建一个 package.json
文件,复制这个文件的内容。这个文件中会包含 NPM 的依赖包,这些依赖很多都是 Hardhat 所需的。然后运行 yarn install
来安装所有的依赖。当安装完成以后,在项目目录下查看 node_modules
文件夹,这个文件夹会包含所有下载好的依赖文件。
这个教程会使用 Hardhat 的本地区块链网络,这意味着我们并没有真正接触以太坊的主网和测试网。如果你想要在以太坊测试网比如 Rinkeby 中测试,请参考 Repo 的 README。
在你的项目根目录下,运行 /
来初始化 Hardhat 并且选择第四个选项:“create an empty hardhat.config.js”
等待 Hardhat 的初始化完成以后,在项目根目录会有一个空白的 hardhat.config.js
文件。如果你想要部署在测试网上的话,那么就复制这个 配置信息 到hardhat.config.js
中,对不同的测试网和主网的进行参数配置。另外还有一点需要注意,如果使用测试网或者主网,还需要用到以太坊节点运营商的接口,比如 Infura, Alchemy 或者 Moralis。
如果你现在不想部署在测试网或者主网上,那就复制下面的配置文件。我后面将会将这个文件称作“Hardhat Config”,别忘了 export 这个对象(module.exports = {defaultNetwork: {...} } )
。这个对象有项目的配置信息,能够定义默认,hardhat 和本地网络。
下面的最小配置就是你 export 的对象中的内容,你可以把链接中的文件替换为如下的配置:
require("@nomiclabs/hardhat-waffle");
require("@nomiclabs/hardhat-etherscan");
require("hardhat-deploy");
require("solidity-coverage");
require("hardhat-contract-sizer");
require("dotenv").config();
module.exports = {
defaultNetwork: "hardhat",
networks: {
hardhat: {
chainId: 31337,
},
localhost: {
chainId: 31337,
},
},
namedAccounts: {
deployer: {
default: 0, // here this will by default take the first account as deployer
1: 0, // similarly on mainnet (network 1) it will take the first
//account as deployer. Note though that depending on how hardhat network are
//configured, the account 0 on one network can be different than on another
},
},
solidity: {
compilers: [
{
version: "0.8.7",
},
{
version: "0.4.24",
},
],
},
mocha: {
timeout: 200000, // 200 seconds max for running tests
},
};
现在,你的项目会有以下文件夹:
Contracts
文件夹,这里有我们 NFT 交易所的逻辑和 NFT 样例合约。deploy
文件夹,这里有 hardhat-deploy plugin 和部署脚本,它们可以编译智能合约并且部署在 Hardhat 提供的本地区块链中。scripts
文件夹,这里有一些脚本文件,用来和部署在本地的 Hardhat 开发环境中的智能合约交互。
接下来,让我们开始开发 NFT 交易所合约。
开发 NFT 交易所
在项目目录下,创建 contracts
文件夹。在文件夹中,然后创建 NftMarketplace.sol
文件(文件路径应该是 ../<< root >>/contracts/NftMarketplace.sol
)。
在 NftMarketplace
这个智能合约中,需要完成之前提到的不同的操作。这些方法如下所示:
function listItem(
address nftAddress,
uint256 tokenId,
uint256 price
) {}
function cancelListing(address nftAddress, uint256 tokenId){}
function buyItem(address nftAddress, uint256 tokenId){}
function updateListing(
address nftAddress,
uint256 tokenId,
uint256 newPrice
){}
function withdrawProceeds(){} // method caller should be withdrawer
function getListing(address nftAddress, uint256 tokenId){}
尽管看起来很简单,但智能合约还有很多必要的检查,现在深入研究一下。我们要保证智能合约不被重入攻击,重入攻击一般是对重复执行本来不该执行的代码来获利,通常是重复执行通证转账操作。
在实现这个交易所的逻辑时,我们需要使用下列的属性和数据架构:
- 1 个结构体:
Listing
用来存储价格和卖房资产变量 - 3 个事件:
ItemListed
,ItemCanceled
和ItemBought
。 - 2 个 mapping:
s_listings
和s_proceeds
,它们存储在区块链上的状态变量。 - 3 个函数修饰器。
别着急,继续看下面的智能合约的时候,你就会明白上面的东西。
让我们先声明智能合约。
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.7;
import "@openzeppelin/contracts/token/ERC721/IERC721.sol";
import "@openzeppelin/contracts/security/ReentrancyGuard.sol";
contract NftMarketplace is ReentrancyGuard {
// TODO…
}
可以看到,我们从 OpenZeppelin 中引入了两个文件,OpenZeppelin 提供了开源且经过审计的,安全智能合约合约模版。我们的智能合约继承了它的 ReentrancyGuard
智能合约(在 Github 上查看),这个智能合约中有我们需要用到的修饰符和方法,用来防止重入攻击。
我们还引入了 IERC721.sol 文件,这个接口我们马上就会用到。然而,我们的交易所智能合约不会继承 ERC-721 通证标准,因为交易所合约不是一个通证合约。
实现 listItem()
让我们从 listItem()
函数开始,我们需要把它定义为一个 external
函数,因为它会被外部合约或者终端用户调用(比如说从网页前端)。我们需要 listItem()
做下面的操作:
- 保证这个正在被上架的物品还没有上架。我们通过 Solidity 函数修饰符 来保证这点。
- 保证正在上架这个物品的人(正在调用这个方法)是它的的所有人。
- 保证这个通证的智能合约已经 “允许” 我们的 NFT 交易所来操作这个通证(比如说转账和其他操作)
- 检查它的价格是否高于 0 wei
- 发送一个 event 记录上架操作
- 在智能合约中存储上架的明细(比如交易所的状态)
函数代码如下:
function listItem(
address nftAddress,
uint256 tokenId,
uint256 price
)
external
notListed(nftAddress, tokenId, msg.sender)
isOwner(nftAddress, tokenId, msg.sender)
{
if (price <= 0) {
revert PriceMustBeAboveZero();
}
IERC721 nft = IERC721(nftAddress);
if (nft.getApproved(tokenId) != address(this)) {
revert NotApprovedForMarketplace();
}
s_listings[nftAddress][tokenId] = Listing(price, msg.sender);
emit ItemListed(msg.sender, nftAddress, tokenId, price);
}
函数修饰符,实践和状态变量
现在实现函数修饰符,事件以及这个 App 的存储数据的状态变量。以下代码中的评论会说明这些东西都使用在了哪里,或者你可以参考 GitHub 代码仓库。
contract NftMarketplace is ReentrancyGuard {
struct Listing {
uint256 price;
address seller;
}
event ItemListed(
address indexed seller,
address indexed nftAddress,
uint256 indexed tokenId,
uint256 price
);
// State Variables
mapping(address => mapping(uint256 => Listing)) private s_listings;
mapping(address => uint256) private s_proceeds;
// Function modifiers
modifier notListed(
address nftAddress,
uint256 tokenId,
address owner
) {
Listing memory listing = s_listings[nftAddress][tokenId];
if (listing.price > 0) {
revert AlreadyListed(nftAddress, tokenId);
}
_;
}
modifier isOwner(
address nftAddress,
uint256 tokenId,
address spender
) {
IERC721 nft = IERC721(nftAddress);
address owner = nft.ownerOf(tokenId);
if (spender != owner) {
revert NotOwner();
}
_;
}
//….. Rest of smart contract …..
}
可以看到:
Listing
这个结构体存储了两个数据 - 卖家的以太坊地址和卖家这个 NFT 的价格。- 状态变量
s_listings
是一个mapping
,将 NFT 的智能合约地址和 token ID 对应起来,Token ID 会指向Listing
这个结构体,使得 Token ID 可以指向 NFT 的卖家地址和价格。 - 状态变量
s_proceeds
将卖家的地址与他们卖 NFT 所赚到的钱相绑定。 ItemListed
事件记录的信息包括 - 卖家的地址,token ID,token 合约地址和上架的物品的价格。
我们还有两个函数修饰符。第一个是notListed
,用来确认这个 token ID 现在并没有被上架(我们不想重复上架 s_listings
中已经包含的物品)。如果这个 token 已经上架,交易会被 revert,返回 AlreadyListed
错误(等一会来写这些错误)。 notListed
还会获得 token 更多的细节以检查正在上架的 token 的价格是否大于零(如果你还记得,我们的 listItem()
方法要要求价格比如大于零。如果这个条件没有被满足,交易会被 revert 并且返回 PriceMustBeAboveZero()
错误)。
第二个修饰符是 isOwner()
,它是用来检查调用 listItem()
函数的地址是否拥有这个物品。如果没有,对于 listItem()
的调用会被 revert 并且返回 notOwner()
错误。
自定义错误信息
现在让看一下这些错误。它们是 solidity 中的自定义错误,我们还没有实现它们。它们实际上是在智能合约主体之外声明的。现在来声明这些错误,因为我们马上就要在交易所函数中用到它们。注意我们需要在引用声明以后,和智能合约声明之前来声明它们。
import "@openzeppelin/contracts/security/ReentrancyGuard.sol";
error PriceNotMet(address nftAddress, uint256 tokenId, uint256 price);
error ItemNotForSale(address nftAddress, uint256 tokenId);
error NotListed(address nftAddress, uint256 tokenId);
error AlreadyListed(address nftAddress, uint256 tokenId);
error NoProceeds();
error NotOwner();
error NotApprovedForMarketplace();
error PriceMustBeAboveZero();
contract NftMarketplace is ReentrancyGuard { … }
同时请注意,自定义错误可以选择传入或者不传入参数。如果参数被传入,可以包含这个错误的有效信息。
你可以看到我们的第一个函数,listItem()
函数使用了其中3个错误:
NotOwner()
错误(通过isOwner()
修饰符)。PriceMustBeAboveZero()
错误。NotApprovedForMarketplace()
错误。
这些错误会被“抛出”,在 Solidity 意味着在执行函数的时候,如果遇到这些状态就会失败,并且被“revert”。
实现 cancelListing()
如果一个 token 的持有者想要下架他的的 token,不再在 Listing 中保持这个 token 的信息。这意味着我们必须在 s_listings
这个 mapping 中删除对应的条目。所以我们在 listItem()
以后写这个函数。
function cancelListing(address nftAddress, uint256 tokenId)
external
isOwner(nftAddress, tokenId, msg.sender)
isListed(nftAddress, tokenId)
{
delete (s_listings[nftAddress][tokenId]);
emit ItemCanceled(msg.sender, nftAddress, tokenId);
}
这是一个可以外部调用的函数,它的参数是 token 的合约地址和 token ID。它用了两个函数修饰符来检查函数调用者是否这个 token 的拥有者并且这个 token 是否已经上架了(因为我们要下架物品,所以还是有必要检查下)。然后删除这些上架的物品,在事件中记录。
我们还没有实现修饰符 isOwner
- 我们已经完成反向检查, isNotOwner
!回到代码的修饰符部分,插入下面的代码:
modifier isListed(address nftAddress, uint256 tokenId) {
Listing memory listing = s_listings[nftAddress][tokenId];
if (listing.price <= 0) {
revert NotListed(nftAddress, tokenId);
}
_;
}
上述代码的逻辑和 isNotListed
是相反的。如果 item 还没有上架,这个修饰符会将交易 revert,然而对于 isNotListed
,当这个 item 是上架的,才会 revert 交易。如果不好理解,记住这个修饰符叫什么名字 – 名字指的是检查会通过的状态。
现在还需要写 ItemCanceled
这个事件,在 cancelListing()
这个函数中会被记录。但是我们还需要在其他的函数中使用很多其他事件,就直接现在把它们全部实现吧。
其他的事件
看一下我们的操作和函数列表,就知道我们接下来需要写 ItemCanceled
和 ItemBought
这两个事件。
所以,在 ItemListed
事件下面,我们插入以下代码:
event ItemCanceled(
address indexed seller,
address indexed nftAddress,
uint256 indexed tokenId
);
event ItemBought(
address indexed buyer,
address indexed nftAddress,
uint256 indexed tokenId,
uint256 price
);
你会看到这两个事件唯一的区别就是一个用来记录卖方,而另一个用来记录买方,非常简单。
现在,让我们实现交易所剩下的操作。
实现 BuyItem()
这个函数是交易所的最重要的。它直接处理支付 - 意味着真正去交易 NFT 和一些其他的电子资产。在这个例子中,我们将会让交易所接收最小单位是 wei 的以太币。这也是我们需要重入攻击保护的地方,我们之前讨论过这个保护是为了防止恶意账户提空所有的代币。
基于所有要求,我们需要保证以下:
- BuyItem() 函数可以被外币调用,接受支付,防止重入攻击。
- 收到的支付要被加入到卖家的状态中。
- 在交易以后,上架物要被删除。
- NFT 需要被转移给购买者。
- 事件要被正确地记录。
注意这里有一个重要的假设,卖家没有取消交易所能够操作其 NFT 的授权。还记得我们在 ListItem()
函数中进行了检查,但是在上架和销售之间的这段事件,卖家有可能会改变取消授权。
所以,经过了上述考虑,函数代码如下:
function buyItem(address nftAddress, uint256 tokenId)
external
payable
isListed(nftAddress, tokenId)
nonReentrant
{
Listing memory listedItem = s_listings[nftAddress][tokenId];
if (msg.value < listedItem.price) {
revert PriceNotMet(nftAddress, tokenId, listedItem.price);
}
s_proceeds[listedItem.seller] += msg.value;
delete (s_listings[nftAddress][tokenId]);
IERC721(nftAddress).safeTransferFrom(listedItem.seller, msg.sender, tokenId);
emit ItemBought(msg.sender, nftAddress, tokenId, listedItem.price);
}
以上述的代码中,我们在 s_proceeds
中更新了卖家的余额。这个余额记录了卖家通过销售 NFT 收到的以太币的总量。然后我们调用了被上架的 NFT 的合约把转移所有权转移给买家(msg.sender
就是在调用这个函数的买家)。但是我们没有把他们的收入发送给他们,这是因为我们后续还会有一个 withdrawProceeds
函数。这个模式“被动提出”而不是“主动发送”这些收入,这篇文章记录了这个设计理念。实际上,让卖家主动取出资金比我们的交易所合约将资金发给他要更加安全,因为主动发送会导致一些我们的智能合约无法控制的执行错误。更好的方式是让卖家自己有转移销售收入的权利,也承担它的责任,我们的智能合约只负责更新销售收入的余额。
实现 updateListing()
这个函数允许卖家可以更新他们上架物的价格,只需要实现下列操作:
- 检查这个 item 是否已经上架并且合约调用者是都拥有这个 NFT。
- 检查这个新的价格是否是非零数值。
- 防御重入攻击。
- 更新
s_listing mapping
的状态,Listing
数据指向更新后的价格。 - 记录正确的事件。
代码如下
function updateListing(
address nftAddress,
uint256 tokenId,
uint256 newPrice
)
external
isListed(nftAddress, tokenId)
nonReentrant
isOwner(nftAddress, tokenId, msg.sender)
{
if (newPrice == 0) {
revert PriceMustBeAboveZero();
}
s_listings[nftAddress][tokenId].price = newPrice;
emit ItemListed(msg.sender, nftAddress, tokenId, newPrice);
}
完成 withdrawProceeds()
正如之前讨论的,因为在实现 BuyItem() 的时候,使用“被动提出”的函数,所以提走销售收入这个操作做的是将合约调用者的余额发给他,不论他在 s_proceeds
中的状态变量是多少。如果这个调用者没有任何的销售收入,我们就会 revert 交易并且返回自定义错误 NoProceeds()
。在我们简单的交易所合约中,如果状态变量正确更新,这个方式就是可行的。当然更重要的是,在发送完成后,我们需要将卖家的销售收入余额清零。
function withdrawProceeds() external {
uint256 proceeds = s_proceeds[msg.sender];
if (proceeds <= 0) {
revert NoProceeds();
}
s_proceeds[msg.sender] = 0;
(bool success, ) = payable(msg.sender).call{value: proceeds}("");
require(success, "Transfer failed");
}
在上述函数中,有一个比较特别的声明:payable(msg.sender).call{value: proceeds}("");
。这个是新版本的 Solidity 给调用者发送代币的方式。这里的 value 指的是被发送的以太币的数量,奇怪的 (“”)
其实是在说这个 Solidity 的 call()
函数在调用的时候没有参数的,被传入的是一个空字符串。
这个 .call()
函数返回两个值,一个布尔型的返回值表示交易成功与否,另一个返回值 - 我们不需要使用因此也不把它赋值给任何变量。
实现 Utility Getter 函数
现在我们几乎已经完成我们的 NFT 交易所!我们只需要再增加两个功能型函数。一个功能型函数会帮助我们通过一个 token ID 获得 Listing
对象,我们可以卖家是谁,上架的时候价格是多少。第二个功能型函数帮助我们获得一个卖家的赚了多少(比如,他们的销售收入数据),这两个函数是对于我们存储在状态变量中的特定数值的“getter”函数。
function getListing(address nftAddress, uint256 tokenId)
external
view
returns (Listing memory)
{
return s_listings[nftAddress][tokenId];
}
function getProceeds(address seller) external view returns (uint256) {
return s_proceeds[seller];
}
有了这两个方法,我们的 NFT 交易所就完成了!现在我们需要做的是写一些脚本部署到Hardhat 本地区块链并且上架一些 NFT。
在我们继续之前,让我们快速编译以下检查有没有出错。在你的命令行,在项目根目录下,运行 yarn hardhat compile
。如果运行成功,那么恭喜你。如果没有运行成功,请检查错误信息,重新看一遍你的流程,Debug 也是开发过程中重要的一部分!
NFT 样例合约
但是在我们写脚本之前,我们需要一个 NFT 样例合约,有了它我们才能铸造 NFT 然后在我们的交易所中上架。我们会使用 ERC721 标准,所以我们可以继承 OpenZeppelin 的 ERC721 库。在代码仓库,有两个 NFT 样例合约,路径是
。它们不需要在test 文件夹下,而是应该在 contracts
文件夹下。
让我们开发 BasicNft.sol
,这个合约会指向一个存在 IPFS 上狗的图片,这是我们的 NFT的艺术部分。这个 NFT 合约非常的简单。
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.7;
import "@openzeppelin/contracts/token/ERC721/ERC721.sol";
contract BasicNft is ERC721 {
string public constant TOKEN_URI =
"ipfs://bafybeig37ioir76s7mg5oobetncojcm3c3hxasyd4rvid4jqhy4gkaheg4/?filename=0-PUG.json";
uint256 private s_tokenCounter;
event DogMinted(uint256 indexed tokenId);
constructor() ERC721("Dogie", "DOG") {
s_tokenCounter = 0;
}
function mintNft() public {
_safeMint(msg.sender, s_tokenCounter);
emit DogMinted(s_tokenCounter);
s_tokenCounter = s_tokenCounter + 1;
}
function tokenURI(uint256 tokenId) public view override returns (string memory) {
require(_exists(tokenId), "ERC721Metadata: URI query for nonexistent token");
return TOKEN_URI;
}
function getTokenCounter() public view returns (uint256) {
return s_tokenCounter;
}
}
tokenURI()
和 getTokenCounter()
是状态变量的 getter 函数。真正的逻辑在 mintNft()
这个函数中,这个函数是我们从 OpenZeppelin 的 ERC721 基础合约继承的。mintNft()
铸造了NFT 并且将 msg.sender
(函数的调用者)注册为 NFT 的拥有者,同时将 Token ID 作为第二个参数传入。我们正在使用 s_tokenCounter
这个状态变量来追踪有多少 NFT 被铸造出来以及它们的 Token ID。所以我们在每次记录 DogMinted
事件的之后都需要给 counter 增加1。
如果想要在交易所中有超过一种的 NFT 的话(这部分是可选的),你可以从这里复制第二个 NFT 合约,名字叫 BasicNftTwo.sol
。你会看到这个合约和 BasicNft.sol
非常像,只是 TOKEN_URI 这个状态变量指向来不同的 IPFS 文件(另一个品种的狗)。注意这两个合约都在<
。
再次运行 yarn hardhat compile
,看看是否所有的东西都可以正常编译,另外再检查下你的 NFT 交易所合约和 NFT 合约在编译中没有报错。
部署脚本
现在,可以开始写部署脚本了。
首先理解什么是部署脚本。部署脚本将 Solidity 编译成 bytecode,bytcode 可以被部署在 EVM 兼容的区块链上被运行。智能合约就是存储在区块链上的一些可以被执行的数据,它们存储方式是 bytecode。编译中还会产生一个 JSON 文件,这个JSON 文件包含了合约的 metadata,metadata 包含了很多有用的信息,比如 ABI(application binary interface),ABI 是我们和智能合约交互的接口。
所以部署脚本编译了智能合约,并且将它们部署在区块链上。可以参考 Hardhat 文档中合约部署部分以了解原理。在这里我们使用一个叫做 hardhat-deploy
的 NPM 包,它会帮助我们自动化生成这些步骤。如果我们增加一个 deploy
的 Hardhat task 并且把它添加进 hardhat 的注册任务中,部署脚本还会自动运行我们存储在一个叫 deploy 文件夹中的脚本,所以我们需要做的就是通过 yarn hardhat deploy
运行我们所有的脚本。
所以,先创建 deploy 文件夹,这个文件夹在文件结构中和 contracts
是同一级,比如:<
。
部署 NftMarketplace.sol
你可以看下述代码,但是请确保你的 Hardhat 配置文件(hardhat.config.js)已经引入了这个代码仓库中有的合约,Hardhat 在部署脚本中加入了很多对象。在 deploy 文件夹中,创建一个 01-deploy-nft-marketplace.js
脚本。我们使用 01-
前缀来给我们的脚本编号,这样它们就可以被 hardhat-deploy 按顺序执行。
const { network } = require("hardhat")
const { developmentChains, VERIFICATION_BLOCK_CONFIRMATIONS } = require("../helper-hardhat-config")
const { verify } = require("../utils/verify")
module.exports = async ({ getNamedAccounts, deployments }) => {
const { deploy, log } = deployments
const { deployer } = await getNamedAccounts()
const waitBlockConfirmations = developmentChains.includes(network.name)
? 1
: VERIFICATION_BLOCK_CONFIRMATIONS
log("----------------------------------------------------")
const arguments = []
const nftMarketplace = await deploy("NftMarketplace", {
from: deployer,
args: arguments,
log: true,
waitConfirmations: waitBlockConfirmations,
})
// Verify the deployment
if (!developmentChains.includes(network.name) && process.env.ETHERSCAN_API_KEY) {
log("Verifying...")
await verify(nftMarketplace.address, arguments)
}
log("----------------------------------------------------")
}
module.exports.tags = ["all", "nftmarketplace"]
所以,这个代码在做什么?首先,引入了我们需要的相关的对象和 item,我会稍后解释 helper-hardhat-config 和 utils/verify 的引用。
然后我们可以导出一个异步函数,这个函数的参数是一个对象。这个参数是 HRE(Hardhat runtime environment:Hardhat 运行时环境),HRE 是一个我们所需的开发工具的集合。HRE 还包含一些插件,包括我们之前讨论的 hard-deploy 插件。
这个异步函数做了下述事情:
- 通过功能函数部署合约,然后在控制台打印出日志信息。
- 找到 hardhat.config.js 文件的 namedAccounts 属性,获取到合约部署人的 index。这个 index 默认是 0,代表着 deployer。这是一个用于部署合约的 Hardhat 钱包(即账户,这个测试账户是由 Hardhat 提供的)。请在这里和这里查看这些技术细节。
通过使用 HRE 中的 deploy()
函数,我们可以在 Hardhat 的本地开发链上部署我们的合约,然后传入配置信息,提供合约部署人的地址,合约构造函数的参数和其他的一些信息。
你还可以看到我们引入并且使用了 developmentChains 。它可以根据不同的配置来使用不同的环境,在这个脚本中的逻辑是判断我们是否使用了开发链,体现在代码中就是 hardhat 还是 localhost。你可以在 hardhat.config.js
文件中看到这些网络。
在我们的项目根目录下,我们要创建一个叫做 helper-hardhat-config.js 的文件,然后从这里导出两个数据:
const developmentChains = ["hardhat", "localhost"]
const VERIFICATION_BLOCK_CONFIRMATIONS = 6
module.exports = {developmentChains, VERIFICATION_BLOCK_CONFIRMATIONS }
VERIFICATION_BLOCK_CONFIRMATIONS
指的是在确认一个交易(也包括创建一个新的智能合约)上链之前,前面要有多少个区块。
在 01 脚本中的开发链配置中,只需要等待一个区块就可以确认交易成功。如果不使用 Hardhat 提供的在本地链的话(比如说你在使用测试网或者主网),我们只能通过 Etherscan 查看交易情况,这时需要用到 Etherscan API key(Infura 有免费版本,但是这个教程中不需要,因为用的是 Hardhat 在本地的链)。想要了解 verify()
的功能是怎样实现的话,请查看 Hardhat 文档。
尽管我们不需要在本地网络中验证,但是我们还是会把这个参数留在脚本中所以我们可以知道有它。我们引入了一个 verify() 功能函数,我们需要在目录中创建它,让代码去编译。我们先创建 <
文件夹,然后把 verify.js
文件放在里面。你可以在这里复制这部分代码。
这段脚本最核心的部分是将 NftMarketplace.sol
智能合约部署到 Hardhat 本地开发网络上。如果你运行 yarn hardhat
,你会在其中看到 deploy 任务。如果没有,你需要检查你的 hardhat.config.js
文件和 deploy 文件夹,确保他们和这篇文章中和代码仓库中的一致。
图中是“deploy”任务在命令行中正确显示
然后通过命令 yarn hardhat deploy
运行这个任务。如果你遇到“Unrecognized task deploy” 错误,那就是 hardhat-deploy 的配置文件是不正确的 - 部署任务没有像上图一样,在可用任务列表中显示出来。
如果所有都正常的话,那就会有信息说你已经编译和部署了交易所合约,同时有一个交易哈希和已部署智能合约的以太坊地址。
图中是 NftMarketplace.sol 成功部署
有14 个 Solidity 被编译的原因是我们所依赖的 OpenZeppein 库也需要被编译。
部署 NFT 合约
接下来,我们创建 02-deploy-basic-nft.js
脚本,这个脚本将 NFT 部署到我们的本地开发链上面。你会注意到这个脚本很眼熟,因为函数名和执行逻辑都和交易所合约的脚本一样。最大的不同正如脚本名字所表示,即我们正在部署的是 NFT 样例合约而不是 NFT 交易所合约。
const { network } = require("hardhat")
const { developmentChains, VERIFICATION_BLOCK_CONFIRMATIONS } = require("../helper-hardhat-config")
const { verify } = require("../utils/verify")
module.exports = async ({ getNamedAccounts, deployments }) => {
const { deploy, log } = deployments
const { deployer } = await getNamedAccounts()
const waitBlockConfirmations = developmentChains.includes(network.name)
? 1
: VERIFICATION_BLOCK_CONFIRMATIONS
log("----------------------------------------------------")
const args = []
const basicNft = await deploy("BasicNft", {
from: deployer,
args: args,
log: true,
waitConfirmations: waitBlockConfirmations,
})
const basicNftTwo = await deploy("BasicNftTwo", {
from: deployer,
args: args,
log: true,
waitConfirmations: waitBlockConfirmations,
})
// Verify the deployment
if (!developmentChains.includes(network.name) && process.env.ETHERSCAN_API_KEY) {
log("Verifying...")
await verify(basicNft.address, args)
await verify(basicNftTwo.address, args)
}
log("----------------------------------------------------")
}
module.exports.tags = ["all", "basicnft"]
和之前一样,运行 yarn hardhat deploy
命令来部署所有的合约。
成功运行 “deploy” 任务编译和部署所有智能合约
在 <
中还有第三个脚本。现在还用不到,这个脚本是用来将 ABI JSON 文件(在编译以后,这些 JSON 文件会被存储到 <
)复制到另一个文件夹的,这个文件夹有前端网页 app 做 UI 展示让我们可以与合约交互。这部分超出来这篇文章的范围,所以我们不需要创建这个脚本。你的 deploy 文件夹应该只包含我们在这边文章中提到的两个脚本。
单独的 RPC 网络
在讨论交互脚本之前,先总结一下。我们已经写了两个 NFT 合约和一个 NFT 交易所合约,并且已经用部署脚本在 Hardhat 本地开发链上部署来这些合约。但是现在我们要怎么与这些合约交互,来查看交易所合约是有在文章开始所讨论的功能呢?
这里就要提到交互脚本来。交互脚本也是用 JavaScript 语言编写,与部署脚本很相似,但是他们的目的是可编程地与我们已经部署的智能合约交互。
为了能够和已经部署的合约交互,我们需要使用一个与 Hardhat 所提供的本地开发链稍微不同的网络。这次我们要使用的网络叫做 localhost,这个配置信息在 hardhat.config.js
文件中。我们叫这个“独立的网络(standalone network)”(之前我们运行的“过程中(in process)”的网络)。你可以在这个 Hardhat 文档中了解这些内容。这个 Hardhat 独立网络通过 localhost IP 地址 127.0.0.1 和端口 8545 来提供 JSON RPC 接口。
在你的控制台,输入 yarn hardhat
然后查看“AVAILABLE TASKS(可用的任务)”下的内容。你会看到有一个叫做 node 的任务,这个任务就是用来 “在 Hardhat 的 EVM 之上启动一个 JSON-RPC 服务器”。这个JSON-PRC 服务器就是独立的 localhost 开发链。
输入命令 yarn hardhat node
,你会得到多个控制台输出的结果,往下滑动你肯看到像下述下面的信息:
图中是启动独立的 JSON-RPC本地开发链
node 任务会编译我们的智能合约,并且把它们部署部署到独立的开发链。同时它也显示它“在 http://127.0.0.1:8545/ 启动来 HTTP 和 WebSocket JSON-RPC 服务器”。
在这个信息下面,它输出来很多钱包地址和它们的私钥。这些是以太坊开发账户,永远不要使用它们去发送真实的交易!
有一个独立的 RPC 接口的好处是,可以将前端的应用和开发网络以及上面的智能合约相连接。我们甚至可以将 Metamask 这样的钱包应用连接上来。
现在我们理解了独立网络是做什么的,ctrl+c
关闭这个网络。每次更新智能合约,我们都需要重新部署。如果智能合约出了问题,也需要关闭和重启本地的开发链以重置合约的数据。
交互脚本
为了和我们的智能合约交互,我们需要做以下事情:
- 通过部署脚本部署合约(index 为 0 的 Hardhat 测试账户)。
- 通过拥有者的账户,铸造并且上架 3 个 NFT(index 为 1 的 Hardhat 测试账户)。
- 通过卖家账户购买编号为 0 的 NFT(index 为 2 的 Hardhat 测试账户)。
- 更新编号为 1 的 NFT 的价格并且检查上架价格是否变化。
- 下架编号为 2 的 NFT 并且检查它是否下架。
- 在编号为 0 的 NFT 卖出以后,检查交易所是否正确地记录了拥有者/卖家的收入。
这些步骤覆盖了我们交易所合约的主要操作。这篇文章中,这部分代码和代码仓库中有一些不同,但是在这个 gist 的对应文件中,你可以找到所有的脚本代码。
铸造并且上架 3 个
为了能够可编程地与已部署的合约交互,我们需要使用 hardhat-ethers plugin 插件。这个插件包含了 ehter.js 库,可以提供 EVM 链的很多有用的 API。
在 NFT 铸造以后,所有权可以被拥有者或者非拥有者转移,你要清楚这里的差异。在文章开始,NFT 的拥有者可以授权给一个以太坊地址,这个被授权的以太坊地址可以执行这个 NFT 相关的操作,比如转移。这就意味着在某个用户铸造一个 NFT 以后,它们需要“授权”给交易所合约,这样交易所合约可以在 buyItem()
函数被调用的时候转移 NFT 的所有权。你可以在这里了解关于 ERC721 的 approve()
函数。
注意在下面的代码中,所有者铸造了一个 NFT,然后授权给交易所合约可以代替所有者操作这个 NFT,然后这个拥有者将这个 NFT 上架。要在代码上实现这个逻辑,首先在项目目录中,创建一个新的文件夹 <
。然后创建一个新的文件 mint-and-list-item.js
,将下述代复制到这个新的文件中。
const { ethers } = require("hardhat")
const PRICE = ethers.utils.parseEther("0.1")
async function mintAndList() {
const accounts = await ethers.getSigners()
const [deployer, owner, buyer1] = accounts
const IDENTITIES = {
[deployer.address]: "DEPLOYER",
[owner.address]: "OWNER",
[buyer1.address]: "BUYER_1",
}
const nftMarketplaceContract = await ethers.getContract("NftMarketplace")
const basicNftContract = await ethers.getContract("BasicNft")
console.log(Minting NFT for ${owner.address})
const mintTx = await basicNftContract.connect(owner).mintNft()
const mintTxReceipt = await mintTx.wait(1)
const tokenId = mintTxReceipt.events[0].args.tokenId
console.log("Approving Marketplace as operator of NFT...")
const approvalTx = await basicNftContract
.connect(owner)
.approve(nftMarketplaceContract.address, tokenId)
await approvalTx.wait(1)
console.log("Listing NFT...")
const tx = await nftMarketplaceContract
.connect(owner)
.listItem(basicNftContract.address, tokenId, PRICE)
await tx.wait(1)
console.log("NFT Listed with token ID: ", tokenId.toString())
const mintedBy = await basicNftContract.ownerOf(tokenId)
console.log(
NFT with ID ${tokenId} minted and listed by owner ${mintedBy}
with identity ${IDENTITIES[mintedBy]}.
)
}
mintAndList()
.then(() => process.exit(0))
.catch((error) => {
console.error(error)
process.exit(1)
})
让我们分解学习这段代码,因为在剩下的脚本中也使用同样的模式。
首先,注意异步函数 mintAndList() 在底部被调用了,如果有错误,我们会把错误在控制台打印出来然后退出,同时给出非正常退出的错误。
在函数体,我们可以通过函数 getSigners() 得到有 20 个 Hardhat 账户的列表,然后我们使用 JS 解构赋值获得前三个地址对象然后给它们变量名 - deployer 是部署智能合约的地址, owner 铸造和拥有 NFT,buyer1 会在下一个脚本中购买 NFT。
我们还创建了一个 IDENTITIES 将地址映射到它们的角色,它能够让我们在控制台debug 的时候更方便。
然后我们进入到合约中,使用铸造,授权和上架操作。我们可以确认 mintedBy 是从交易所智能合约中读到的并且这个地址与 owner 一致。上架的时候,我们传入 PRICE 这个常量,这个常量是以 wei 为单位计量的 0.1 个以太币。你会注意到我们使用了 ehter.js 功能函数 parseEther() 来做这个转换(文档)。
在我们运行这个脚本之前,我们需要准备好 Hardhat RPC 本地测试网。我们需要两个打开的终端来做这件事。在第一个终端中,输入 yarn hardhat node ,我们前面所讨论的 standalone RPC 网络会启动。
在第二个终端窗口中,输入 yarn hardhat run scripts/mint-and-list-item.js --network localhost
。如果正确执行的话,你会看到像下面一样的结果:
成功铸造和上架你的 NFT
如果你想知道本地 RPC 服务器怎么记录你的交易,可以切换到第一个终端窗口那里查看输出。
有了编号为 0 的 NFT 以后,我们需要再铸造 2 个。运行同样的 mint-and-list-item.js 脚本两次,token ID 的编号会到 2.
购买一个 NFT
从我们上面的脚本中,你应该记得接下来我们想要使用其中一个账号去购买 token ID 为 0 的 NFT。这里第二个脚本的结构和 mint-and-list 一样,但是我们需要脚本中的逻辑。像下面代码一样,在 scripts 文件夹中,创建一个新的 buy-item.js
脚本:
const { ethers } = require("hardhat")
const TOKEN_ID = 0 // SET THIS BEFORE RUNNING SCRIPT
async function buyItem() {
const accounts = await ethers.getSigners()
const [deployer, owner, buyer1] = accounts
const IDENTITIES = {
[deployer.address]: "DEPLOYER",
[owner.address]: "OWNER",
[buyer1.address]: "BUYER_1",
}
const nftMarketplaceContract = await ethers.getContract("NftMarketplace")
const basicNftContract = await ethers.getContract("BasicNft")
const listing = await nftMarketplaceContract
.getListing(basicNftContract.address, TOKEN_ID)
const price = listing.price.toString()
const tx = await nftMarketplaceContract
.connect(buyer1)
.buyItem(basicNftContract.address, TOKEN_ID, {
value: price,
})
await tx.wait(1)
console.log("NFT Bought!")
const newOwner = await basicNftContract.ownerOf(TOKEN_ID)
console.log(
New owner of Token ID ${TOKEN_ID} is ${newOwner} with identity of
${IDENTITIES[newOwner]}
)
}
buyItem()
.then(() => process.exit(0))
.catch((error) => {
console.error(error)
process.exit(1)
})
这个脚本和之前的几乎一样。最大的不同是在这个脚本中,我们使用了 ehter.js 的 .connect(signer)
函数去给另一个账户发送交易。在这个场景下,我们想要确定 buyer1 是交易所合约中的函数 buyItem() 的调用者。同时,我们使用 getListing() 函数读取 Listing 的数据来获得价格,因为我们在 buyItem() 中需要知道要支付多少钱。最终,我们直接读取 NFT 合约的状态变量,确定所有者是否变成了 buyer。
运行命令 yarn hardhat run scripts/buy-item --network localhost
,然后你应该看到以下结果:
图中成功购买一个上架的 NFT
更新 NFT 的价格
现在我们已经卖出了 Token 0,它在交易所中已经下架了。下一步就是更新 Token ID 1 的价格,token 1 在开始的时候就被铸造和上架了。模式在这里是重复的,所以创建 update-listing
脚本,然后从这个 gist 复制代码。如果你运行 yarn hardhat run scripts/update-listing --network localhost
,然后有设置了正确的 TOKEN_ID,那么你就会看到下面的日志信息,表示价格已经被更新了:
图中是成功更新了 NFT 的价格
最后两个操作
只剩下两个脚本了 -- 一个是下架 NFT,另一个是让拥有者/卖家知道他们卖掉的 NFT 的收入。和刚才一样,在 gist 中复制代码,在运行命令 scripts/cancel-item.js
之后,你会在控制台看到如下的日志信息:
图中是成功下架一个 NFT
运行 get-seller-proceeds.js
命令,查看 NFT 的所有者在卖出他们的 NFT 以后获得了多少收入,运行结果应该如下所示:
图中查看存储在交易所智能合约中的卖家收入
注意卖家的收入存储在交易所合约的 mapping s_proceeds
中,是以 wei 为单位的。我们可以使用 ether.js 的功能函数 .formatEther()(文档)来将他转为可读的以太币数量。
总结
不管什么时候,让代码可以自动化测试都是个好习惯。你可以查看这个代码仓库,这里有一系列的测试,这些测试使用 Hardhat 工具,Hardhat 工具借助了 Mocha 和 Waffle 的功能。这些测试还检查了 revert(),函数修饰符和异常处理。这些测试非常有用,值得了解和学习,所以去查看一下它们吧。