跳到主要内容

完善宠物蛋功能

在上一小节中,我们开发并测试了最初版本的宠物蛋合约,在本小节中,我们将进一步完善其功能。

编写智能合约

将上述egg.sol中的代码扩展为下面的内容:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.17;

import "@openzeppelin/contracts/token/ERC721/ERC721.sol";
import "@openzeppelin/contracts/access/AccessControl.sol";
import "@openzeppelin/contracts/utils/Counters.sol";

contract iCatEgg is ERC721, AccessControl {

bytes32 public constant ADMIN_ROLE = keccak256("ADMIN_ROLE");

using Counters for Counters.Counter;
Counters.Counter private _tokenIdCounter;

enum Color {
WHITE,
GREEN,
BLUE,
PURPLE,
RED
}

mapping ( uint256 => Color ) colorOfEgg;
mapping ( address => uint256[] ) public ownedTokenId; // 查看拥有的所有tokenId

// 使用error处理错误更加省gas
error notOwner(uint256 tokenId, address account);

constructor() ERC721("iCat Egg", "EGG") {
_grantRole(ADMIN_ROLE, msg.sender);
}

function getColor(uint256 tokenId) public view returns (Color) {
return colorOfEgg[tokenId];
}

function totalSupply() public view returns (uint256) {
return _tokenIdCounter.current();
}

function _baseURI() internal pure override returns (string memory) {
return "https://";
}

function getOwnedTokenId(address owner) public view returns (uint256[] memory, uint256) {
return (ownedTokenId[owner], ownedTokenId[owner].length);
}

// 铸造蛋
function mint() public {
// 随机赋予蛋颜色
uint256 randomNumber = uint256(
keccak256(abi.encodePacked(block.timestamp, block.difficulty, msg.sender))
);
uint256 enumLength = uint256(Color.RED) + 1;
uint256 selectedIndex = randomNumber % enumLength;
uint256 tokenId = _tokenIdCounter.current();
_tokenIdCounter.increment();
colorOfEgg[tokenId] = Color(selectedIndex);

// 铸造蛋NFT
_safeMint(msg.sender, tokenId);
ownedTokenId[tx.origin].push(tokenId);
}

// 二分查找特定值的索引
function binarySearch(uint256[] storage arr, uint256 value) internal view returns (int256) {
int256 left = 0;
int256 right = int256(arr.length) - 1;

while (left <= right) {
int256 mid = left + (right - left) / 2;
if (arr[uint256(mid)] == value) {
return mid;
}
if (arr[uint256(mid)] < value) {
left = mid + 1;
} else {
right = mid - 1;
}
}

return -1;
}

// 孵化蛋
function hatchOut(uint256 tokenId) public {
// 只有蛋的拥有者才能孵化
if (ownerOf(tokenId) != msg.sender) {
revert notOwner(tokenId, msg.sender);
}

// 燃烧掉蛋,铸造iCat
_burn(tokenId);
int256 index = binarySearch(ownedTokenId[msg.sender], tokenId);
if (index >= 0) {
for (uint256 i = uint256(index); i < ownedTokenId[msg.sender].length - 1; i++) {
ownedTokenId[msg.sender][i] = ownedTokenId[msg.sender][i + 1];
}
ownedTokenId[msg.sender].pop();
}
}


/**
* @dev This is the admin function
*/
function grantAdmin(address account) public onlyRole(ADMIN_ROLE) {
_grantRole(ADMIN_ROLE, account);
}


/**
* @dev The following functions are overrides required by Solidity.
*/
function supportsInterface(bytes4 interfaceId)
public
view
override(ERC721, AccessControl)
returns (bool)
{
return super.supportsInterface(interfaceId);
}

}

让我们尽可能拆解上述代码进行讲解。

import "@openzeppelin/contracts/access/AccessControl.sol";
import "@openzeppelin/contracts/utils/Counters.sol";

这两行通过引入两个 OpenZeppelin 的库,来解决智能合约的细粒度访问控制,以及实现ERC721代币计数管理。

bytes32 public constant ADMIN_ROLE = keccak256("ADMIN_ROLE");

这行代码是将字符串形式的ADMIN_ROLE通过keccak256函数哈希化为一个bytes32变量,有利于gas优化和slot管理。

using Counters for Counters.Counter;
Counters.Counter private _tokenIdCounter;

这两行代码是将Counters的运算规则赋予Counters.counter,方便后续运算;然后创建一个新的计数变量_tokenIdCounter

enum Color {
WHITE,
GREEN,
BLUE,
PURPLE,
RED
}

这几行是创建了一组enum变量,最后得到的就是WHITE为 0 ,GREEN为 1 ,以此类推,enum变量可以与uint256等类型相互转化。

mapping ( uint256 => Color ) colorOfEgg;
mapping ( address => uint256[] ) public ownedTokenId; // 查看拥有的所有tokenId

这两行是创建了两个映射变量,colorOfEgg用以查看某个tokenId的宠物蛋NFT的颜色,ownedTokenId用以查看某地址所拥有的所有宠物蛋NFT的tokenId(不包括已经 burn 掉的)。

error notOwner(uint256 tokenId, address account);

这行是 solidity 0.8 之后添加的新的错误处理方式,不同于以往的 revertrequire,通过error方式进行错误处理,能够对错误进行统一管理,并且限制了错误消息的长度,更加省gas,我们后面会经常使用这种错误处理方式。

constructor() ERC721("iCat Egg", "EGG") {
_grantRole(ADMIN_ROLE, msg.sender);
}

这里我们在构造函数终添加了一行_grantRole(ADMIN_ROLE, msg.sender);,通过调用 OpenZeppelin 的 AccessControl 中的库函数,给部署本合约的地址(msg.sender)授予ADMIN_ROLE

function getColor(uint256 tokenId) public view returns (Color) {
return colorOfEgg[tokenId];
}

function totalSupply() public view returns (uint256) {
return _tokenIdCounter.current();
}

function _baseURI() internal pure override returns (string memory) {
return "https://";
}

function getOwnedTokenId(address owner) public view returns (uint256[] memory, uint256) {
return (ownedTokenId[owner], ownedTokenId[owner].length);
}

上述四个函数分别用来获取宠物蛋的颜色、目前共有多少宠物蛋在流通、宠物蛋的 metadata URI、以及某个地址所拥有的宠物蛋所有编号以及总数量。

信息

最通用的区块浏览器 Etherscan 通过读取totalSupply函数来获取目前流通的该种 NFT 的数量,因此需要自定义一个totalSupply函数来供 Etherscan 调用。

提示

注意上述函数名后面的修饰成分,包括publicviewpure等,一定要清楚每一个修饰成分的用法。

function mint() public {
// 随机赋予蛋颜色
uint256 randomNumber = uint256(
keccak256(abi.encodePacked(block.timestamp, block.difficulty, msg.sender))
);
uint256 enumLength = uint256(Color.RED) + 1;
uint256 selectedIndex = randomNumber % enumLength;
uint256 tokenId = _tokenIdCounter.current();
_tokenIdCounter.increment();
colorOfEgg[tokenId] = Color(selectedIndex);

// 铸造蛋NFT
_safeMint(msg.sender, tokenId);
ownedTokenId[tx.origin].push(tokenId);
}

上述函数中,第 3-7 行用以生成一个随机的颜色变量,使得每一次铸造的新的宠物蛋都能够随机获得颜色。剩下的代码就是tokenId顺延加1,并为msg.sender铸造 token id 为tokenId的NFT。

注意

注意,由于以太坊主网已经从 PoW 过度到了 PoS ,因此block.difficulty已经被弃用了,如果想要在以太坊主网上部署智能合约,请不要这样生成随机数。

// 孵化蛋
function hatchOut(uint256 tokenId) public {
// 只有蛋的拥有者才能孵化
if (ownerOf(tokenId) != msg.sender) {
revert notOwner(tokenId, msg.sender);
}

// 燃烧掉蛋,铸造iCat
_burn(tokenId);
int256 index = binarySearch(ownedTokenId[msg.sender], tokenId);
if (index >= 0) {
for (uint256 i = uint256(index); i < ownedTokenId[msg.sender].length - 1; i++) {
ownedTokenId[msg.sender][i] = ownedTokenId[msg.sender][i + 1];
}
ownedTokenId[msg.sender].pop();
}
}

上述代码实现了孵化宠物蛋的功能,首先燃烧掉宠物蛋,并通过合约内调用icat合约 的mint函数铸造一个新的宠物猫(等 icat 合约写好后添加)。

function grantAdmin(address account) public onlyRole(ADMIN_ROLE) {
_grantRole(ADMIN_ROLE, account);
}

上述代码实现了ADMIN_ROLE的权限授予。


至此,合约代码设计完成。接下来,就是合约测试环节。

测试智能合约

仍然使用 hardhat 编写智能合约测试用例。

编写测试用例

打开test.js,在main函数中console.log下一行插入下面代码:

const [guy, randomGuy, hacker] = await ethers.getSigners();
// 铸造一个蛋
const mintEgg = await eggContract.mint();
console.log("Mint succesful");

// 查看蛋的颜色
const getColor = await eggContract.getColor(0);
console.log("The color of egg #0 is", getColor);

// 授予第二个admin以admin权限
const grantAdmin = await eggContract.grantAdmin(randomGuy.address);
console.log("grant successful")

// 孵化蛋
const hatch = await eggContract.hatchOut(0);
console.log("Hatched out successfully");

让我们逐行来看

const [guy, randomGuy, hacker] = await ethers.getSigners();

hardhat 在运行脚本时,会使用助记词test test test test test test test test test test test junk生成一系列钱包,在脚本执行过程中,会默认使用生成的第一个地址。通过本行代码,可以逐个获取改助记词下生成的一系列地址,例如,在本代码中,guyrandomGuyhacker分别为该助记词下生成的前三个地址。

const mintEgg = await eggContract.mint();
console.log("Mint succesful");

这两行代码用以使用默认地址铸造一个宠物蛋,铸造成功之后输出Mint successful,铸造不成功则中断并抛出异常。

后面的测试用例用途均可从注释获取,这里不做进一步阐述。

运行测试用例

打开终端,执行以下命令:

npx hardhat run .\scripts\test.js

看到以下输出则证明执行正确。

Compiled 16 Solidity files successfully
NFT contract deployed to: 0x5FbDB2315678afecb367f032d93F642f64180aa3
Mint succesful
The color of egg #0 is 0
grant successful
Hatched out successfully