NFT一日通——Azuki的ERC721A

本文尝试发行一款NFT,我们从合约出发而不是直接使用Opensea来搞定定制化发布,在此之前你需要先熟悉Solidity这门合约语言,并拥有Metamask钱包和账户。

NFT基本认识

NFT是什么

NFT是Non-fungible token的缩写,即非同质化代币,是一种在区块链上的数据单位,既然是token,那么其就具备了资产属性。这种资产不可互换,可以被确权。技术上作品本身可以被复制,但是作品的拥有权被区块链所记录且完整追踪,所有权是唯一的。另一方面,非同质化是说:所有的房屋都是不同的,没有两只小猫是一样的。NFT可区分,必须分别跟踪每个所有者。和ERC20的区别就在此:每一个token在ERC20都是一样的,而且可以分割。

ERC721是什么

ERC是Ethereum Request for Comments的缩写,是以太坊智能合约开发人员使用的技术文档,定义一组准则或协议。比如ERC20定义了代币标准,ERC721定义了NFT标准。其中ERC721依赖ERC165。

如何发行NFT

一种是使用Opensea这类网站发行,准备好Gas fee后,只需要点几次鼠标,写一点介绍就可以了。我们这次不准备用这种方式,而是通过智能合约来发布一款NFT。

发行NFT的流程

首先我们要明确我们的NFT所锚定的对象,是一组图片还是一组音乐、N段故事、贷款,亦或是实体财产。我们发行的NFT的数量,发行价格,是否包含free mint,白名单等等一系列经济模型。简单说有如下几步:

  1. 熟读ERC721合约的标准,看一看现有的实现
  2. 设计NFT,故事,白皮书等
  3. 编写NFT合约,并上链;周边配套的开发,比如一款DApp,DApp的开发不是本篇的重点,我们只关注合约部分
  4. 做社区,拉盘

安装与初始化本地Hardhat

Ethereum合约开发的工作流的市场份额当前被Hardhat占据主导地位,本篇我们使用Hardhat开发我们的合约。请按照下面的步骤来配置:

1
2
3
4
5
6
7
8
9
$ mkdir my-erc721
$ cd my-erc721
$ npm init
$ npm install --save-dev hardhat
$ npx hardhat
...
👷 Welcome to Hardhat v2.9.6 👷‍
✔ What do you want to do? · Create a basic sample project # 选择适合我们的选项
...

安装好后,我们会在contracts目录内编写我们的智能合约,在scripts中编写js语言的部署脚本,并在hardhat.config.js配置部署和编译信息等。

NFT合约编写

目前最为流行的ERC721标准实现当然是鼎鼎大名的OpenZeppelin,他的实现在安全性、完成度、可维护性和测试等方面都做的很不错。但是其gas fee存在一些较大消耗。这里我们使用知名NFT项目azukiERC721A实现。这是一版改进实现,特别重视了合约的运行效率,优化了gas fee,而且合约依赖更少。

先安装合约:

1
2
$ npm install --save-dev erc721a
$ npm install @openzeppelin/contracts

接着我们编写合约代码:

1
2
3
4
5
6
7
8
9
10
11
12
pragma solidity ^0.8.4;

import "erc721a/contracts/ERC721A.sol"; // 导入ERC721A

contract ZY is ERC721A {
constructor() ERC721A("Zy", "ZY") {}

function mint(uint256 quantity) external payable {
// _safeMint's second argument now takes in a quantity, not a tokenId.
_safeMint(msg.sender, quantity);
}
}

因为Azuki的ERC721A已经帮助我们实现绝大部分功能了,所以最简单的使用就是定义mint函数。当然因为没有定义白名单,mint相关的限制等(而这些需要根据白皮书的内容来确定),这个合约并非产品级代码,还需要大量的细化特别是安全性相关的测试。

完成后编译合约:

1
$ npx hardhat compile

改写默认的sample-scripts.js部署脚本如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
const hre = require("hardhat");

async function main() {
const NFT = await hre.ethers.getContractFactory("NFT");
const nft = await NFT.deploy();

await nft.deployed();

console.log("My NFT deployed to:", nft.address);
}

main()
.then(() => process.exit(0))
.catch((error) => {
console.error(error);
process.exit(1);
});

使用Infura进行线上测试

Infura是最早和目前最大的Ethereum的Relay Network解决方案,提供一些公开的Gataway节点。我们会与Infura的api进行交互,而Infura作为中介会在他们的云上管理的节点中执行相关操作完成最终调用。

配置rinkeby

这是NFT交易中主流的测试网络。

1
2
3
4
5
6
7
8
$ npm install dotenv
$ touch .env
# 编辑.env
PRIVATE_KEY=[打开钱包-账号详情->导出私钥] 把私钥填写在这里,切忌不要透露私钥给任何人
API=https://rinkeby.infura.io/v3/{以及你的ProjectID,从Infura复制}
PUBLIC_KEY=metamask钱包地址
NETWORK=rinkeby
API_KEY=Infura上新建项目的ProjectID

配置hardhat.config.js,以下为新增内容(不含既有内容):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
require('dotenv').config();
const { API, PRIVATE_KEY } = process.env;

module.exports = {
solidity: "0.8.4",
defaultNetwork: "rinkeby",
networks: {
hardhat: {},
rinkeby: {
url: API,
accounts: [`0x${PRIVATE_KEY}`]
}
},
};

Rinkeby Authenticated Faucet水龙头获取一些测试用ETH。我们复制Metamask钱包地址,并发一条twitter,然后复制这条推特的地址输入以获得以太:

twitter

如果拿不到那就多试几个,总有可以的:Paradigm Rinkeby Faucet。当我们的钱包中有以太后,开始部署合约,并在rinkeby Etherscan找到我们的合约信息:

1
2
$ npx hardhat --network rinkeby run scripts/sample-script.js 
My NFT deployed to: 0x...[合约地址]

部署信息查询如下:

deploy

一些具体信息,比如gas fee:

gas fee

mint第一个NFT

编写我们的mint脚本scripts/mint.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
require("dotenv").config()
const hh = require("hardhat");
const PRIVATE_KEY = process.env.PRIVATE_KEY
const NETWORK = process.env.NETWORK
const API_KEY = process.env.API_KEY

const provider = new hh.ethers.providers.InfuraProvider(NETWORK, API_KEY);
const abi = require("../artifacts/contracts/NFT.sol/ZY.json").abi
const contractAddress = "合约地址在这里补充"
const contract = new hh.ethers.Contract(contractAddress, abi, provider)
const wallet = new hh.ethers.Wallet(PRIVATE_KEY, provider)

async function main() {
const contractWithSigner = contract.connect(wallet);
const price = 100; // mint的代价,这里是100wei;正式部署当然要定义到合约中
console.log("price is " + price);
const tx = await contractWithSigner.mint(1, { value: price});
console.log(tx.hash);
await tx.wait();
console.log("mint success");
}

main()
.then(() => process.exit(0))
.catch((error) => {
console.error(error);
process.exit(1);
});

接着执行合约,查看效果:

1
2
3
4
$ npx hardhat --network rinkeby run scripts/mint.js
price is 100
0x36d2eedc4b...[transaction的Id]
mint success

mint

使用去中心文件服务设置资源

我们选择的NFT是没有和任何资源(图片、音乐等)锚定的,这需要我们新手设定,详见IERC721 Metadata-tokenURI。在ERC721A中,我们可以找到_baseURI函数的代码,这个函数的返回值就是资源的根目录,也就是说NFT的资源。默认是返回'',也就是无资源设定,我们需要override并加入可以设定资源URI的方法:

1
2
3
4
5
6
7
8
/**
* @dev Base URI for computing {tokenURI}. If set, the resulting URI for each
* token will be the concatenation of the `baseURI` and the `tokenId`. Empty
* by default, can be overriden in child contracts.
*/
function _baseURI() internal view virtual returns (string memory)

function tokenURI(uint256 tokenId) override view public returns (string memory)

我们选择IPFS服务来存放我们的资源文件(mirror.xyz使用Arweave服务,考虑到支持度的问题,可能得用https网关,所以我们暂时还是选用受众较广的IPFS)。这里可以选用Pinata。注册账户后按照提示上传我们事先准备好的图片资源文件。这里我们可以选择上传整个文件夹,而不是单张图片。然后我们准备metadata文件夹,并上传:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
$ mkdir img      # 放入图片,名字用数字从0开始,比如0.png, 1.png ..
...
$ mkdir metadata # 对于每一张图片,都准备一个metadata,文件名是0, 1, ..
$ cd metadata
$ touch 0
$ vim 0 # 编辑metadata文件
# 文件0中内容如下示例:
{
"name": "my-nft",
"attributes": [
{
"trait_type": "tokenID",
"value": "0"
}
],
"description": "a image",
# 下面的image填写Pinata上面的CID地址;当然也可以是http的
"image": "ipfs://QmVxF5gdE4hu27tGp3jTfxf7j3cBzhmyYcZeFTP9irgmdQ/0.png"
}

这里给出一个带有白名单、free mint、可以self destruct和可设定外部资源的合约,仅供参考:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
pragma solidity ^0.8.4;

import "erc721a/contracts/ERC721A.sol";
import "@openzeppelin/contracts/access/Ownable.sol";

contract ZY is ERC721A, Ownable {
event Log(string message, uint number);
event Mint(string message, address indexed receiver);
uint256 public constant PRICE_PER_TOKEN = 0.0001 ether;
string private _resourceURI;
address[] private white_list;
address[] private free_mint;
mapping(address => bool) public minted;

constructor() ERC721A("zy", "ZY") {}

modifier gasCheck() {
uint cnt = 0;
for (uint i = 0; i < white_list.length; ++i) {
if (!minted[white_list[i]]) {
cnt += 1;
}
}
require(msg.value >= cnt * PRICE_PER_TOKEN, "Not Enough Gas for Mint");
_;
}

function mint(uint256 quantity) external payable onlyOwner() {
// _safeMint's second argument now takes in a quantity, not a tokenId.
_safeMint(msg.sender, quantity);
}

function mintList(address[] storage list) internal {
uint cnt = 0;
for (uint i = 0; i < list.length; ++i) {
address current = list[i];
if (!minted[current]) {
_safeMint(current, 1);
minted[current] = true;
emit Mint("Minted to ", current);
cnt += 1;
}
}
emit Log("Totally Minted ", cnt);
}

function mint2wl() external payable onlyOwner() gasCheck() {
mintList(white_list);
}

function mint2free() external payable onlyOwner() {
mintList(free_mint);
}

function addWhiteListMember(address wlMember) external onlyOwner() {
white_list.push(wlMember);
}

function addFreeMintMember(address member) external onlyOwner() {
free_mint.push(member);
}

function setResourceURI(string calldata resourceURI_) external onlyOwner() {
_resourceURI = resourceURI_;
}

function _baseURI() internal view virtual override returns (string memory) {
return _resourceURI;
}

function kill() external onlyOwner() {
selfdestruct(payable(msg.sender));
}
}

上述合约的gas报告,这是通过hardhat-gas-reporer来完成的。实时价格获取是通过coinmarketcap,需要注册并获得一个免费的API供测试用:

gas report

IPFS官方也有相关的教程可供参考。接下来我们补充合约交互JS脚本:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
// 头部和上面的JS一致,配置钱包、合约地址和API

async function mint() {
const contractWithSigner = contract.connect(wallet);
const price = 100; // mint的代价,需要多少Ether,正式部署当然要定义到合约中
console.log("price is " + price);
const tx = await contractWithSigner.mint(1, { value: price});
console.log(tx.hash);
await tx.wait();
console.log("mint success");
}

async function kill() {
const contractWithSigner = contract.connect(wallet);
const tx = await contractWithSigner.kill()
console.log(tx.hash);
await tx.wait();
console.log("self destruct success");
}

async function setResourceURI() {
const contractWithSigner = contract.connect(wallet);
// 注意这里不是HTTP Gateway
// 例如:ipfs://QmZ6Qrb9AZcMSyQJKAwkT7eUgjG8o3ixhcfrNv8Hye35dX/
// 不要忘记最后的'/'_____^
const tx = await contractWithSigner.setResourceURI("这里填写你的资源文件URI")
console.log(tx.hash);
await tx.wait();
console.log("setBaseURL success");
}

// main函数的执行和上面JS一样

Opensea测试网的效果如下:


后记

Opensea TestnetsLooksrare Rinkeby都可以查看我们的mint结果,执行完事务后到上面去看看吧。本篇文章的例子