如何编写 NFT 智能合约

## 简介
在之前的教程中,我们向你展示了如何使用我们的[生成艺术库](https://github.com/rounakbanik/generative-art-nft)来创建一个[头像集合](https://dev.to/rounakbanik/create-generative-nft-art-with-rarities-1n6f),生成符合要求的NFT元数据,并[将元数据JSON和媒体文件上传至IPFS](https://dev.to/rounakbanik/working-with-nft-metadata-ipfs-and-pinata-3ieh)。
然而,我们还没有把头像铸成NFT。因此,在本教程中,我们将编写一个智能合约,允许任何人通过支付Gas从我们的藏品中铸造一个NFT。
## 前提

1. 了解Javascript的中级知识(如果你需要复习,我建议使用这个[YouTube教程](https://www.youtube.com/watch?v=NCwa_xi0Uuc))。
2. 了解Solidity和OpenZeppelin合约的中级知识(推荐[CryptoZombies](https://cryptozombies.io/en/course/)和[Buildpace](https://buildspace.so/))。
3. 在本地电脑上安装node和npm
4. 准备好一组媒体文件和NFT元数据JSON上传至IPFS。(如果你没有这个,我们已经创建了一个玩具集供你实验。你可以在[这里](https://ipfs.io/ipfs/QmUygfragP8UmCa7aq19AHLttxiLw1ELnqcsQQpM5crgTF)找到媒体文件和在[这里](https://ipfs.io/ipfs/QmZbWNKJPAjxXuNFSEaksCJVd1M6DaKQViJBYPK2BdpDEP)找到JSON元数据文件)。
虽然不满足先决条件的读者可能会跟着做,甚至可以部署一个智能合约,但如果你对你的项目很认真,我们强烈建议找一个知道自己在做什么的开发者。智能合约的开发和部署可能是非常昂贵的,而且在安全缺陷和bug方面也不宽容。
## 设置本地开发环境

我们将使用Hardhat,一个行业标准的以太坊开发环境,来开发、部署和验证我们的智能合约。为项目创建一个空文件夹,并通过在终端运行以下命令初始化一个空package.json文件:
“`
mkdir nft-collectible && cd nft-collectible && npm init -y
“`
你现在应该在`nft-collectible`文件夹内,并有一个名为`package.json`的文件。
接下来,让我们安装Hardhat。运行以下命令:
“`
npm install –save-dev hardhat
“`
现在我们可以通过运行以下命令并选择 “Create a basic sample project(创建一个基本样本项目)”来创建项目:
“`
npx hardhat
“`
同意所有的默认值(项目根目录,添加`.gitignore`,并安装所有样本项目的依赖项)。
让我们检查样本项目是否已经正确安装,运行以下命令:
“`
npx hardhat run scripts/sample-script.js
“`
如果一切顺利,你应该看到像这样的输出:

我们现在已经成功地配置了Hardhat开发环境。现在安装OpenZeppelin合约包。这将使我们能够访问ERC721合约(NFT的标准),以及一些我们以后会遇到的辅助库:
“`
npm install @openzeppelin/contracts
“`
如果我们要公开分享项目的代码(在GitHub这样的网站上),我们不想分享敏感信息,比如私钥、Etherscan API密钥或我们的Alchemy URL(如果其中一些词对你还没有意义,请不要担心)。因此,让我们安装另一个名为dotenv的库:
“`
npm install dotenv
“`
我们现在可以开始开发智能合约了。
## 编写智能合约

在这一节中,我们将在[Solidity](https://learnblockchain.cn/docs/solidity)中编写一个智能合约,允许任何人通过支付所需数量的以太币+Gas来铸造一定数量的NFT。
在你项目的`contracts`文件夹中,创建一个名为`NFTCollectible.sol`的新文件。
我们将使用Solidity v8.0,合约将继承OpenZeppelin的`ERC721Enumerable`和`Ownable`合约。前者有一个ERC721(NFT)标准的默认实现,此外还有一些在处理NFT时有用的辅助函数。后者允许我们在合约的增加管理权限。
除了上述内容,还将使用OpenZeppelin的`SafeMath`和`Counters`库来分别安全地处理无符号整数运算(通过防止溢出)和tokenID。
这就是我们合约的骨架,看起来像这样:
“`solidity
//SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
import “@openzeppelin/contracts/utils/Counters.sol”;
import “@openzeppelin/contracts/access/Ownable.sol”;
import “@openzeppelin/contracts/utils/math/SafeMath.sol”;
import “@openzeppelin/contracts/token/ERC721/extensions/ERC721Enumerable.sol”;
contract NFTCollectible is ERC721Enumerable, Ownable {
using SafeMath for uint256;
using Counters for Counters.Counter;
Counters.Counter private _tokenIds;
}
“`
### 常量和变量
我们的合约需要跟踪某些变量和常量。在本教程中,定义以下内容:
1. **供应量(Supply)**:可以铸造的NFT的最大数量。
2. **价格**:购买1个NFT所需的以太币数量。
3. **每次交易的最大铸币数量**:你一次可以铸造的NFT的上限。
4. **代币URI前缀(baseTokenURI)**:包含JSON元数据的文件夹的IPFS URL。
在本教程中,我们将把1-3设置为常数。换句话说,一旦合约被部署,我们将无法修改它们。我们将为`baseTokenURI`编写一个setter函数,允许合约的所有者(或部署者)在需要时修改它。
在`_tokenIds`声明下,添加以下内容:
“`solidity
uint public constant MAX_SUPPLY = 100;
uint public constant PRICE = 0.01 ether;
uint public constant MAX_PER_MINT = 5;
string public baseTokenURI;
“`
注意,常数使用了大写字母。请根据你的项目自由改变常数的值。
### 构造函数
我们将在构造函数的调用中设置`baseTokenURI`。还将调用父级构造函数并为NFT合约设置名称和符号。
因此,构造函数看起来像这样:
“`solidity
constructor(string memory baseURI) ERC721(“NFT Collectible”, “NFTC”) {
setBaseURI(baseURI);
}
“`
### 保留NFT的功能
作为项目的创建者,你可能想为你自己、团队以及像赠品这样的活动保留一些NFT的集合。
让我们写一个函数,允许我们免费铸造一定数量的NFT(在这里为10个)。由于调用这个函数的人只需要支付Gas费,显然需要把它标记为`onlyOwner`,这样只有合约的所有者才能调用它:
“`solidity
function reserveNFTs() public onlyOwner {
uint totalMinted = _tokenIds.current();
require(
totalMinted.add(10) < MAX_SUPPLY, “Not enough NFTs”
);
for (uint i = 0; i < 10; i++) {
_mintSingleNFT();
}
}
“`
我们通过调用`tokenIds.current()`来检查到目前为止铸造的NFT的总数。然后检查是否有足够的NFT供我们保留。如果是,我们继续通过调用`_mintSingleNFT`10次来铸造10个NFT。
在`_mintSingleNFT`函数中,真正的魔法发生了。我们稍后将研究它。
### 设置baseTokenURI
NFT JSON元数据可以在这个IPFS URL上找到:`ipfs://QmZbWNKJPAjxXuNFSEaksCJVd1M6DaKQViJBYPK2BdpDEP/`。
当我们把这个设置为`baseTokenURI`时,OpenZeppelin的实现会自动推导出每个token的URI。它假定token1的元数据在`ipfs://QmZbWNKJPAjxXuNFSEaksCJVd1M6DaKQViJBYPK2BdpDEP/1`,代币2的元数据在`ipfs://QmZbWNKJPAjxXuNFSEaksCJVd1M6DaKQViJBYPK2BdpDEP/2`,等等
(请注意,这些文件没有`.json`扩展名)。
相应的函数是:
“`
function _baseURI() internal
view
virtual
override
returns (string memory) {
return baseTokenURI;
}
function setBaseURI(string memory _baseTokenURI) public onlyOwner {
baseTokenURI = _baseTokenURI;
}
“`
在合约部署之后,合约的所有者允许改变`baseTokenURI`。
### Mint NFT函数
现在让我们把注意力转向主要的Mint NFT函数。当用户和客户想从我们的收藏中购买和铸造NFT时,他们会调用这个函数。
由于他们要向这个函数发送以太币,我们必须将其标记为 `payable`.
在真实铸币发生之前,我们需要做三个检查:
1. 有足够的NFT数量供调用者铸造。
2. 请求的铸币数量超过0,但少于每笔交易允许的最大NFT数量。
3. 调用者已经发送了足够的以太币来铸造所要求的NFT数量。
“`javascript
function mintNFTs(uint _count) public payable {
uint totalMinted = _tokenIds.current();
require(
totalMinted.add(_count) <= MAX_SUPPLY, “Not enough NFTs!”
);
require(
_count > 0 && _count <= MAX_PER_MINT,
“Cannot mint specified number of NFTs.”
);
require(
msg.value >= PRICE.mul(_count),
“Not enough ether to purchase NFTs.”
);
for (uint i = 0; i < _count; i++) {
_mintSingleNFT();
}
}
“`
### 铸造单个NFT函数
最后让我们看看私有的`_mintSingleNFT()`函数,每当我们(或第三方)想铸造一个NFT时,都会调用这个函数:
“`solidity
function _mintSingleNFT() private {
uint newTokenID = _tokenIds.current();
_safeMint(msg.sender, newTokenID);
_tokenIds.increment();
}
“`
这里发生的事情:
1. 得到当前还没有被铸造的 ID。
2. 使用OpenZeppelin已经定义的`_safeMint()`函数,将NFT ID分配给调用该函数的账户。
3. 我们将tokenID的计数器递增1。
在发生任何铸币行为之前,代币ID为0。
当这个函数第一次被调用时,`newTokenID`是0。调用`safeMint()`将ID为0的NFT分配给调用合约函数的人,然后计数器被递增到1。
下次调用此函数时,`_newTokenID`的值为1。调用`safeMint()`将ID为1的NFT分配给……我想你能明白这个要点。
注意,我们不需要为每个NFT再次设置元数据。设置`baseTokenURI`可以确保每个NFT自动获得正确的元数据(存储在IPFS中)。
### 获取一个特定账户所拥有的所有代币
如果你打算给你的NFT持有人提供类似列表类的功能,你会想每个用户持有哪些NFT。
让我们写一个简单的函数,返回一个特定持有人拥有的所有ID。
ERC721Enumerable的 “balanceOf “和 “tokenOfOwnerByIndex “函数使之变得超级简单。前者告诉我们一个特定的所有者持有多少代币,后者可以用来获得一个所有者拥有的所有ID。不过也带来了相应的 gas 成本, 可以阅读:[调整NFT智能合约,减少70%的铸币Gas成本](https://learnblockchain.cn/article/4388)
“`solidity
function tokensOfOwner(address _owner)
external
view
returns (uint[] memory) {
uint tokenCount = balanceOf(_owner);
uint[] memory tokensId = new uint256[](tokenCount);
for (uint i = 0; i < tokenCount; i++) {
tokensId[i] = tokenOfOwnerByIndex(_owner, i);
}
return tokensId;
}
“`
### 提取合约余额功能
如果我们不能提取发送到合约中的以太币,那么我们所做的所有努力都将付诸东流。
让我们写一个函数,允许我们提取合约的全部余额。这显然需要被标记为`onlyOwner`。
“`solidity
function withdraw() public payable onlyOwner {
uint balance = address(this).balance;
require(balance > 0, “No ether left to withdraw”);
(bool success, ) = (msg.sender).call{value: balance}(“”);
require(success, “Transfer failed.”);
}
“`
### 最终合约
我们已经完成了智能合约,代码如下:
“`solidity
//SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
import “@openzeppelin/contracts/utils/Counters.sol”;
import “@openzeppelin/contracts/access/Ownable.sol”;
import “@openzeppelin/contracts/utils/math/SafeMath.sol”;
import “@openzeppelin/contracts/token/ERC721/extensions/ERC721Enumerable.sol”;
contract NFTCollectible is ERC721Enumerable, Ownable {
using SafeMath for uint256;
using Counters for Counters.Counter;
Counters.Counter private _tokenIds;
uint public constant MAX_SUPPLY = 100;
uint public constant PRICE = 0.01 ether;
uint public constant MAX_PER_MINT = 5;
string public baseTokenURI;
constructor(string memory baseURI) ERC721(“NFT Collectible”, “NFTC”) {
setBaseURI(baseURI);
}
function reserveNFTs() public onlyOwner {
uint totalMinted = _tokenIds.current();
require(totalMinted.add(10) < MAX_SUPPLY, “Not enough NFTs left to reserve”);
for (uint i = 0; i < 10; i++) {
_mintSingleNFT();
}
}
function _baseURI() internal view virtual override returns (string memory) {
return baseTokenURI;
}
function setBaseURI(string memory _baseTokenURI) public onlyOwner {
baseTokenURI = _baseTokenURI;
}
function mintNFTs(uint _count) public payable {
uint totalMinted = _tokenIds.current();
require(totalMinted.add(_count) <= MAX_SUPPLY, “Not enough NFTs left!”);
require(_count >0 && _count <= MAX_PER_MINT, “Cannot mint specified number of NFTs.”);
require(msg.value >= PRICE.mul(_count), “Not enough ether to purchase NFTs.”);
for (uint i = 0; i < _count; i++) {
_mintSingleNFT();
}
}
function _mintSingleNFT() private {
uint newTokenID = _tokenIds.current();
_safeMint(msg.sender, newTokenID);
_tokenIds.increment();
}
function tokensOfOwner(address _owner) external view returns (uint[] memory) {
uint tokenCount = balanceOf(_owner);
uint[] memory tokensId = new uint256[](tokenCount);
for (uint i = 0; i < tokenCount; i++) {
tokensId[i] = tokenOfOwnerByIndex(_owner, i);
}
return tokensId;
}
function withdraw() public payable onlyOwner {
uint balance = address(this).balance;
require(balance > 0, “No ether left to withdraw”);
(bool success, ) = (msg.sender).call{value: balance}(“”);
require(success, “Transfer failed.”);
}
}
“`
## 在本地部署合约
现在让我们做准备在本地环境中模拟,以便之后将我们的合约部署到Rinkeby测试网络(或其他的主网)。
在`scripts`文件夹中,创建一个名为`run.js`的新文件并添加以下代码:
“`javascript
const { utils } = require(“ethers”);
async function main() {
const baseTokenURI = “ipfs://QmZbWNKJPAjxXuNFSEaksCJVd1M6DaKQViJBYPK2BdpDEP/”;
// Get owner/deployer’s wallet address
const [owner] = await hre.ethers.getSigners();
// Get contract that we want to deploy
const contractFactory = await hre.ethers.getContractFactory(“NFTCollectible”);
// Deploy contract with the correct constructor arguments
const contract = await contractFactory.deploy(baseTokenURI);
// Wait for this transaction to be mined
await contract.deployed();
// Get contract address
console.log(“Contract deployed to:”, contract.address);
// Reserve NFTs
let txn = await contract.reserveNFTs();
await txn.wait();
console.log(“10 NFTs have been reserved”);
// Mint 3 NFTs by sending 0.03 ether
txn = await contract.mintNFTs(3, { value: utils.parseEther(‘0.03’) });
await txn.wait()
// Get all token IDs of the owner
let tokens = await contract.tokensOfOwner(owner.address)
console.log(“Owner has tokens: “, tokens);
}
main()
.then(() => process.exit(0))
.catch((error) => {
console.error(error);
process.exit(1);
});
“`
这是一些Javascript代码,利用`ethers.js`库来部署合约,然后在合约被部署后调用合约的功能。
下面是发生的一系列事情:
1. 得到部署者/所有者(我们)的地址
2. 得到我们想要部署的合约。
3. 发送一个请求,请求部署该合约,并等待矿工处理这个请求并将其添加到区块链上。
4. 一旦交易被挖出,我们就会得到合约的地址。
5. 然后调用合约的函数。我们保留了10个NFT,以及通过向合约发送0.03ETH来铸造3个NFT,并检查我们拥有的NFT。请注意,前两个调用需要Gas(因为它们是写到区块链上的),而第三个只是从区块链上读取。
让我们在本地运行一下:
“`
npx hardhat run scripts/run.js
“`
如果一切顺利,你应该看到类似这样的输出:

## 将合约部署到Rinkeby上
为了将我们的合约部署到Rinkeby,我们需要进行一些设置。
首先,我们需要一个RPC URL,使我们能够广播合约创建交易。我们将使用Alchemy来做这件事。[在这里创建一个Alchemy账户](https://alchemy.com/?r=7d60e34c-b30a-4ffa-89d4-3c4efea4e14b),然后继续创建一个免费的应用程序。

确保网络被设置为*Rinkeby*。
在创建了应用后,进入你的[Alchemy仪表板](https://dashboard.alchemyapi.io/)并选择你的应用程序。这将打开一个新的窗口,在右上方有一个查看密钥的按钮。点击该按钮并选择HTTP URL。
从[这里水龙头](https://faucet.rinkeby.io/)获得一些假的Rinkeby ETH。对于我们的使用情况,0.5个ETH应该是绰绰有余。一旦你获得了这些ETH,打开你的Metamask扩展,并获得有假ETH的钱包的私钥(你可以通过账户详情来获取)。
**注意:不要公开分享你的URL和私钥**。
我们将使用`dotenv`库将上述变量存储为环境变量,并且不会将它们提交到代码库。
创建一个名为`.env`的新文件,并以下列格式存储你的URL和私钥:
“`
API_URL = “<–YOUR ALCHEMY URL HERE–>”
PRIVATE_KEY = “<–YOUR PRIVATE KEY HERE–>”
“`
现在,用以下内容替换你的`hardhat.config.js`文件:
“`javascript
require(“@nomiclabs/hardhat-waffle”);
require(‘dotenv’).config();
const { API_URL, PRIVATE_KEY } = process.env;
// This is a sample Hardhat task. To learn how to create your own go to
// https://hardhat.org/guides/create-task.html
task(“accounts”, “Prints the list of accounts”, async (taskArgs, hre) => {
const accounts = await hre.ethers.getSigners();
for (const account of accounts) {
console.log(account.address);
}
});
// You need to export an object to set up your config
// Go to https://hardhat.org/config/ to learn more
/**
* @type import(‘hardhat/config’).HardhatUserConfig
*/
module.exports = {
solidity: “0.8.4”,
defaultNetwork: “rinkeby”,
networks: {
rinkeby: {
url: API_URL,
accounts: [PRIVATE_KEY]
}
},
};
“`
我们就快成功了! 运行以下命令:
“`bash
npx hardhat run scripts/run.js –network rinkeby
“`
脚本的输出与之前得到的非常相似,只是现在已经被部署到真正的区块链上。
记下合约地址:这里是0x355638a4eCcb7794257f22f50c289d4189F245。
你可以在Etherscan上查看这个合约。进入Etherscan,输入合约地址,应该看到类似这样的内容:

## 在OpenSea上查看我们的NFT
我们的NFT现在已经可以在OpenSea上使用,不需要我们明确上传。进入[testnets.opensea.io](https://testnets.opensea.io/)并搜索你的合约地址。
这就是我们的藏品的模样:

## 在Etherscan上验证合约代码
在etherscan上验证我们的合约。这将允许用户看到你的合约的代码,并确保没有任何“有趣的事情”发生。更重要的是,验证代码将允许你的用户将他们的Metamask钱包连接到etherscan,并在etherscan上铸造你的NFT!
在这样做之前,我们需要一个Etherscan的API密钥。在[这里](https://etherscan.io/apis)注册一个免费账户,并访问你的API密钥。
让我们把这个API密钥添加到`.env`文件中:
“`
ETHERSCAN_API = “<–YOUR ETHERSCAN API KEY–>”
“`
Hardhat使我们在Etherscan上验证合约变得非常简单。让我们安装以下软件包:
“`
npm install @nomiclabs/hardhat-etherscan
“`
接下来,对 `hardhat.config.js` 进行调整,使其看起来像这样:
“`javascript
require(“@nomiclabs/hardhat-waffle”);
require(“@nomiclabs/hardhat-etherscan”);
require(‘dotenv’).config();
const { API_URL, PRIVATE_KEY, ETHERSCAN_API } = process.env;
// This is a sample Hardhat task. To learn how to create your own go to
// https://hardhat.org/guides/create-task.html
task(“accounts”, “Prints the list of accounts”, async (taskArgs, hre) => {
const accounts = await hre.ethers.getSigners();
for (const account of accounts) {
console.log(account.address);
}
});
// You need to export an object to set up your config
// Go to https://hardhat.org/config/ to learn more
/**
* @type import(‘hardhat/config’).HardhatUserConfig
*/
module.exports = {
solidity: “0.8.4”,
defaultNetwork: “rinkeby”,
networks: {
rinkeby: {
url: API_URL,
accounts: [PRIVATE_KEY]
}
},
etherscan: {
apiKey: ETHERSCAN_API
}
};
“`
现在,运行以下两个命令:
“`
npx hardhat clean
npx hardhat verify –network rinkeby DEPLOYED_CONTRACT_ADDRESS “BASE_TOKEN_URI”
“`
在我们的例子中,第二条命令看起来像这样:
“`
npx hardhat verify –network rinkeby 0x355638a4eCcb777794257f22f50c289d4189F245 “ipfs://QmZbWNKJPAjxXuNFSEaksCJVd1M6DaKQViJBYPK2BdpDEP/”
“`

现在,如果访问你的合约的Rinkeby Etherscan页面,你应该在合约标签旁边看到一个小的绿色勾。更重要的是,用户现在可以使用Metamask连接到web3,并从Etherscan上调用你的合约的功能:

自己试试吧。
连接你用来部署合约的账户,从etherscan调用`withdraw`功能。你应该可以将合约中的0.03个ETH转账到你的钱包里。另外,邀请你的一个朋友连接他们的钱包,通过调用`mintNFT`函数来铸造一些NFT。
## 总结
我们现在有一个已部署的智能合约,可以让用户从我们的合约中铸造NFT。一个明显的下一步是建立一个web3应用程序,让我们的用户可以直接从我们的网站上铸造NFT。这将是[另一个教程](https://learnblockchain.cn/article/4530)的主题。
如果你已经走到了这一步,恭喜你!
最终代码库:https://github.com/rounakbanik/nft-collectible-contract
——
本翻译由 [Duet Protocol](https://duet.finance/?utm_souce=learnblockchain) 赞助支持。
- 原文:https://dev.to/rounakbanik/writing-an-nft-collectible-smart-contract-2nh8
- 译文出自:区块链开发网翻译计划
- 译者:翻译小组
- 校对:Tiny 熊
- 本文永久链接:learnblockchain.cn/article…
简介
在之前的教程中,我们向你展示了如何使用我们的生成艺术库来创建一个头像集合,生成符合要求的NFT元数据,并将元数据JSON和媒体文件上传至IPFS。
然而,我们还没有把头像铸成NFT。因此,在本教程中,我们将编写一个智能合约,允许任何人通过支付Gas从我们的藏品中铸造一个NFT。
前提
- 了解Javascript的中级知识(如果你需要复习,我建议使用这个YouTube教程)。
- 了解Solidity和OpenZeppelin合约的中级知识(推荐CryptoZombies和Buildpace)。
- 在本地电脑上安装node和npm
- 准备好一组媒体文件和NFT元数据JSON上传至IPFS。(如果你没有这个,我们已经创建了一个玩具集供你实验。你可以在这里找到媒体文件和在这里找到JSON元数据文件)。
虽然不满足先决条件的读者可能会跟着做,甚至可以部署一个智能合约,但如果你对你的项目很认真,我们强烈建议找一个知道自己在做什么的开发者。智能合约的开发和部署可能是非常昂贵的,而且在安全缺陷和bug方面也不宽容。
设置本地开发环境
我们将使用Hardhat,一个行业标准的以太坊开发环境,来开发、部署和验证我们的智能合约。为项目创建一个空文件夹,并通过在终端运行以下命令初始化一个空package.json文件:
mkdir nft-collectible && cd nft-collectible && npm init -y
你现在应该在nft-collectible
文件夹内,并有一个名为package.json
的文件。
接下来,让我们安装Hardhat。运行以下命令:
npm install --save-dev hardhat
现在我们可以通过运行以下命令并选择 “Create a basic sample project(创建一个基本样本项目)”来创建项目:
npx hardhat
同意所有的默认值(项目根目录,添加.gitignore
,并安装所有样本项目的依赖项)。
让我们检查样本项目是否已经正确安装,运行以下命令:
npx hardhat run scripts/sample-script.js
如果一切顺利,你应该看到像这样的输出:
我们现在已经成功地配置了Hardhat开发环境。现在安装OpenZeppelin合约包。这将使我们能够访问ERC721合约(NFT的标准),以及一些我们以后会遇到的辅助库:
npm install @openzeppelin/contracts
如果我们要公开分享项目的代码(在GitHub这样的网站上),我们不想分享敏感信息,比如私钥、Etherscan API密钥或我们的Alchemy URL(如果其中一些词对你还没有意义,请不要担心)。因此,让我们安装另一个名为dotenv的库:
npm install dotenv
我们现在可以开始开发智能合约了。
编写智能合约
在这一节中,我们将在Solidity中编写一个智能合约,允许任何人通过支付所需数量的以太币+Gas来铸造一定数量的NFT。
在你项目的contracts
文件夹中,创建一个名为NFTCollectible.sol
的新文件。
我们将使用Solidity v8.0,合约将继承OpenZeppelin的ERC721Enumerable
和Ownable
合约。前者有一个ERC721(NFT)标准的默认实现,此外还有一些在处理NFT时有用的辅助函数。后者允许我们在合约的增加管理权限。
除了上述内容,还将使用OpenZeppelin的SafeMath
和Counters
库来分别安全地处理无符号整数运算(通过防止溢出)和tokenID。
这就是我们合约的骨架,看起来像这样:
//SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
import "@openzeppelin/contracts/utils/Counters.sol";
import "@openzeppelin/contracts/access/Ownable.sol";
import "@openzeppelin/contracts/utils/math/SafeMath.sol";
import "@openzeppelin/contracts/token/ERC721/extensions/ERC721Enumerable.sol";
contract NFTCollectible is ERC721Enumerable, Ownable {
using SafeMath for uint256;
using Counters for Counters.Counter;
Counters.Counter private _tokenIds;
}
常量和变量
我们的合约需要跟踪某些变量和常量。在本教程中,定义以下内容:
- 供应量(Supply):可以铸造的NFT的最大数量。
- 价格:购买1个NFT所需的以太币数量。
- 每次交易的最大铸币数量:你一次可以铸造的NFT的上限。
- 代币URI前缀(baseTokenURI):包含JSON元数据的文件夹的IPFS URL。
在本教程中,我们将把1-3设置为常数。换句话说,一旦合约被部署,我们将无法修改它们。我们将为baseTokenURI
编写一个setter函数,允许合约的所有者(或部署者)在需要时修改它。
在_tokenIds
声明下,添加以下内容:
uint public constant MAX_SUPPLY = 100;
uint public constant PRICE = 0.01 ether;
uint public constant MAX_PER_MINT = 5;
string public baseTokenURI;
注意,常数使用了大写字母。请根据你的项目自由改变常数的值。
构造函数
我们将在构造函数的调用中设置baseTokenURI
。还将调用父级构造函数并为NFT合约设置名称和符号。
因此,构造函数看起来像这样:
constructor(string memory baseURI) ERC721("NFT Collectible", "NFTC") {
setBaseURI(baseURI);
}
保留NFT的功能
作为项目的创建者,你可能想为你自己、团队以及像赠品这样的活动保留一些NFT的集合。
让我们写一个函数,允许我们免费铸造一定数量的NFT(在这里为10个)。由于调用这个函数的人只需要支付Gas费,显然需要把它标记为onlyOwner
,这样只有合约的所有者才能调用它:
function reserveNFTs() public onlyOwner {
uint totalMinted = _tokenIds.current();
require(
totalMinted.add(10) < MAX_SUPPLY, "Not enough NFTs"
);
for (uint i = 0; i < 10; i++) {
_mintSingleNFT();
}
}
我们通过调用tokenIds.current()
来检查到目前为止铸造的NFT的总数。然后检查是否有足够的NFT供我们保留。如果是,我们继续通过调用_mintSingleNFT
10次来铸造10个NFT。
在_mintSingleNFT
函数中,真正的魔法发生了。我们稍后将研究它。
设置baseTokenURI
NFT JSON元数据可以在这个IPFS URL上找到:ipfs://QmZbWNKJPAjxXuNFSEaksCJVd1M6DaKQViJBYPK2BdpDEP/
。
当我们把这个设置为baseTokenURI
时,OpenZeppelin的实现会自动推导出每个token的URI。它假定token1的元数据在ipfs://QmZbWNKJPAjxXuNFSEaksCJVd1M6DaKQViJBYPK2BdpDEP/1
,代币2的元数据在ipfs://QmZbWNKJPAjxXuNFSEaksCJVd1M6DaKQViJBYPK2BdpDEP/2
,等等
(请注意,这些文件没有.json
扩展名)。
相应的函数是:
function _baseURI() internal
view
virtual
override
returns (string memory) {
return baseTokenURI;
}
function setBaseURI(string memory _baseTokenURI) public onlyOwner {
baseTokenURI = _baseTokenURI;
}
在合约部署之后,合约的所有者允许改变baseTokenURI
。
Mint NFT函数
现在让我们把注意力转向主要的Mint NFT函数。当用户和客户想从我们的收藏中购买和铸造NFT时,他们会调用这个函数。
由于他们要向这个函数发送以太币,我们必须将其标记为 payable
.
在真实铸币发生之前,我们需要做三个检查:
- 有足够的NFT数量供调用者铸造。
- 请求的铸币数量超过0,但少于每笔交易允许的最大NFT数量。
- 调用者已经发送了足够的以太币来铸造所要求的NFT数量。
function mintNFTs(uint _count) public payable {
uint totalMinted = _tokenIds.current();
require(
totalMinted.add(_count) <= MAX_SUPPLY, "Not enough NFTs!"
);
require(
_count > 0 && _count <= MAX_PER_MINT,
"Cannot mint specified number of NFTs."
);
require(
msg.value >= PRICE.mul(_count),
"Not enough ether to purchase NFTs."
);
for (uint i = 0; i < _count; i++) {
_mintSingleNFT();
}
}
铸造单个NFT函数
最后让我们看看私有的_mintSingleNFT()
函数,每当我们(或第三方)想铸造一个NFT时,都会调用这个函数:
function _mintSingleNFT() private {
uint newTokenID = _tokenIds.current();
_safeMint(msg.sender, newTokenID);
_tokenIds.increment();
}
这里发生的事情:
- 得到当前还没有被铸造的 ID。
- 使用OpenZeppelin已经定义的
_safeMint()
函数,将NFT ID分配给调用该函数的账户。 - 我们将tokenID的计数器递增1。
在发生任何铸币行为之前,代币ID为0。
当这个函数第一次被调用时,newTokenID
是0。调用safeMint()
将ID为0的NFT分配给调用合约函数的人,然后计数器被递增到1。
下次调用此函数时,_newTokenID
的值为1。调用safeMint()
将ID为1的NFT分配给……我想你能明白这个要点。
注意,我们不需要为每个NFT再次设置元数据。设置baseTokenURI
可以确保每个NFT自动获得正确的元数据(存储在IPFS中)。
获取一个特定账户所拥有的所有代币
如果你打算给你的NFT持有人提供类似列表类的功能,你会想每个用户持有哪些NFT。
让我们写一个简单的函数,返回一个特定持有人拥有的所有ID。
ERC721Enumerable的 “balanceOf “和 “tokenOfOwnerByIndex “函数使之变得超级简单。前者告诉我们一个特定的所有者持有多少代币,后者可以用来获得一个所有者拥有的所有ID。不过也带来了相应的 gas 成本, 可以阅读:调整NFT智能合约,减少70%的铸币Gas成本
function tokensOfOwner(address _owner)
external
view
returns (uint[] memory) {
uint tokenCount = balanceOf(_owner);
uint[] memory tokensId = new uint256[](tokenCount);
for (uint i = 0; i < tokenCount; i++) {
tokensId[i] = tokenOfOwnerByIndex(_owner, i);
}
return tokensId;
}
提取合约余额功能
如果我们不能提取发送到合约中的以太币,那么我们所做的所有努力都将付诸东流。
让我们写一个函数,允许我们提取合约的全部余额。这显然需要被标记为onlyOwner
。
function withdraw() public payable onlyOwner {
uint balance = address(this).balance;
require(balance > 0, "No ether left to withdraw");
(bool success, ) = (msg.sender).call{value: balance}("");
require(success, "Transfer failed.");
}
最终合约
我们已经完成了智能合约,代码如下:
//SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
import "@openzeppelin/contracts/utils/Counters.sol";
import "@openzeppelin/contracts/access/Ownable.sol";
import "@openzeppelin/contracts/utils/math/SafeMath.sol";
import "@openzeppelin/contracts/token/ERC721/extensions/ERC721Enumerable.sol";
contract NFTCollectible is ERC721Enumerable, Ownable {
using SafeMath for uint256;
using Counters for Counters.Counter;
Counters.Counter private _tokenIds;
uint public constant MAX_SUPPLY = 100;
uint public constant PRICE = 0.01 ether;
uint public constant MAX_PER_MINT = 5;
string public baseTokenURI;
constructor(string memory baseURI) ERC721("NFT Collectible", "NFTC") {
setBaseURI(baseURI);
}
function reserveNFTs() public onlyOwner {
uint totalMinted = _tokenIds.current();
require(totalMinted.add(10) < MAX_SUPPLY, "Not enough NFTs left to reserve");
for (uint i = 0; i < 10; i++) {
_mintSingleNFT();
}
}
function _baseURI() internal view virtual override returns (string memory) {
return baseTokenURI;
}
function setBaseURI(string memory _baseTokenURI) public onlyOwner {
baseTokenURI = _baseTokenURI;
}
function mintNFTs(uint _count) public payable {
uint totalMinted = _tokenIds.current();
require(totalMinted.add(_count) <= MAX_SUPPLY, "Not enough NFTs left!");
require(_count >0 && _count <= MAX_PER_MINT, "Cannot mint specified number of NFTs.");
require(msg.value >= PRICE.mul(_count), "Not enough ether to purchase NFTs.");
for (uint i = 0; i < _count; i++) {
_mintSingleNFT();
}
}
function _mintSingleNFT() private {
uint newTokenID = _tokenIds.current();
_safeMint(msg.sender, newTokenID);
_tokenIds.increment();
}
function tokensOfOwner(address _owner) external view returns (uint[] memory) {
uint tokenCount = balanceOf(_owner);
uint[] memory tokensId = new uint256[](tokenCount);
for (uint i = 0; i < tokenCount; i++) {
tokensId[i] = tokenOfOwnerByIndex(_owner, i);
}
return tokensId;
}
function withdraw() public payable onlyOwner {
uint balance = address(this).balance;
require(balance > 0, "No ether left to withdraw");
(bool success, ) = (msg.sender).call{value: balance}("");
require(success, "Transfer failed.");
}
}
在本地部署合约
现在让我们做准备在本地环境中模拟,以便之后将我们的合约部署到Rinkeby测试网络(或其他的主网)。
在scripts
文件夹中,创建一个名为run.js
的新文件并添加以下代码:
const { utils } = require("ethers");
async function main() {
const baseTokenURI = "ipfs://QmZbWNKJPAjxXuNFSEaksCJVd1M6DaKQViJBYPK2BdpDEP/";
// Get owner/deployer's wallet address
const [owner] = await hre.ethers.getSigners();
// Get contract that we want to deploy
const contractFactory = await hre.ethers.getContractFactory("NFTCollectible");
// Deploy contract with the correct constructor arguments
const contract = await contractFactory.deploy(baseTokenURI);
// Wait for this transaction to be mined
await contract.deployed();
// Get contract address
console.log("Contract deployed to:", contract.address);
// Reserve NFTs
let txn = await contract.reserveNFTs();
await txn.wait();
console.log("10 NFTs have been reserved");
// Mint 3 NFTs by sending 0.03 ether
txn = await contract.mintNFTs(3, { value: utils.parseEther('0.03') });
await txn.wait()
// Get all token IDs of the owner
let tokens = await contract.tokensOfOwner(owner.address)
console.log("Owner has tokens: ", tokens);
}
main()
.then(() => process.exit(0))
.catch((error) => {
console.error(error);
process.exit(1);
});
这是一些Javascript代码,利用ethers.js
库来部署合约,然后在合约被部署后调用合约的功能。
下面是发生的一系列事情:
- 得到部署者/所有者(我们)的地址
- 得到我们想要部署的合约。
- 发送一个请求,请求部署该合约,并等待矿工处理这个请求并将其添加到区块链上。
- 一旦交易被挖出,我们就会得到合约的地址。
- 然后调用合约的函数。我们保留了10个NFT,以及通过向合约发送0.03ETH来铸造3个NFT,并检查我们拥有的NFT。请注意,前两个调用需要Gas(因为它们是写到区块链上的),而第三个只是从区块链上读取。
让我们在本地运行一下:
npx hardhat run scripts/run.js
如果一切顺利,你应该看到类似这样的输出:
将合约部署到Rinkeby上
为了将我们的合约部署到Rinkeby,我们需要进行一些设置。
首先,我们需要一个RPC URL,使我们能够广播合约创建交易。我们将使用Alchemy来做这件事。在这里创建一个Alchemy账户,然后继续创建一个免费的应用程序。
确保网络被设置为Rinkeby。
在创建了应用后,进入你的Alchemy仪表板并选择你的应用程序。这将打开一个新的窗口,在右上方有一个查看密钥的按钮。点击该按钮并选择HTTP URL。
从这里水龙头获得一些假的Rinkeby ETH。对于我们的使用情况,0.5个ETH应该是绰绰有余。一旦你获得了这些ETH,打开你的Metamask扩展,并获得有假ETH的钱包的私钥(你可以通过账户详情来获取)。
注意:不要公开分享你的URL和私钥。
我们将使用dotenv
库将上述变量存储为环境变量,并且不会将它们提交到代码库。
创建一个名为.env
的新文件,并以下列格式存储你的URL和私钥:
API_URL = "<--YOUR ALCHEMY URL HERE-->"
PRIVATE_KEY = "<--YOUR PRIVATE KEY HERE-->"
现在,用以下内容替换你的hardhat.config.js
文件:
require("@nomiclabs/hardhat-waffle");
require('dotenv').config();
const { API_URL, PRIVATE_KEY } = process.env;
// This is a sample Hardhat task. To learn how to create your own go to
// https://hardhat.org/guides/create-task.html
task("accounts", "Prints the list of accounts", async (taskArgs, hre) => {
const accounts = await hre.ethers.getSigners();
for (const account of accounts) {
console.log(account.address);
}
});
// You need to export an object to set up your config
// Go to https://hardhat.org/config/ to learn more
/**
* @type import('hardhat/config').HardhatUserConfig
*/
module.exports = {
solidity: "0.8.4",
defaultNetwork: "rinkeby",
networks: {
rinkeby: {
url: API_URL,
accounts: [PRIVATE_KEY]
}
},
};
我们就快成功了! 运行以下命令:
npx hardhat run scripts/run.js --network rinkeby
脚本的输出与之前得到的非常相似,只是现在已经被部署到真正的区块链上。
记下合约地址:这里是0x355638a4eCcb7794257f22f50c289d4189F245。
你可以在Etherscan上查看这个合约。进入Etherscan,输入合约地址,应该看到类似这样的内容:
在OpenSea上查看我们的NFT
我们的NFT现在已经可以在OpenSea上使用,不需要我们明确上传。进入testnets.opensea.io并搜索你的合约地址。
这就是我们的藏品的模样:
在Etherscan上验证合约代码
在etherscan上验证我们的合约。这将允许用户看到你的合约的代码,并确保没有任何“有趣的事情”发生。更重要的是,验证代码将允许你的用户将他们的Metamask钱包连接到etherscan,并在etherscan上铸造你的NFT!
在这样做之前,我们需要一个Etherscan的API密钥。在这里注册一个免费账户,并访问你的API密钥。
让我们把这个API密钥添加到.env
文件中:
ETHERSCAN_API = "<--YOUR ETHERSCAN API KEY-->"
Hardhat使我们在Etherscan上验证合约变得非常简单。让我们安装以下软件包:
npm install @nomiclabs/hardhat-etherscan
接下来,对 hardhat.config.js
进行调整,使其看起来像这样:
require("@nomiclabs/hardhat-waffle");
require("@nomiclabs/hardhat-etherscan");
require('dotenv').config();
const { API_URL, PRIVATE_KEY, ETHERSCAN_API } = process.env;
// This is a sample Hardhat task. To learn how to create your own go to
// https://hardhat.org/guides/create-task.html
task("accounts", "Prints the list of accounts", async (taskArgs, hre) => {
const accounts = await hre.ethers.getSigners();
for (const account of accounts) {
console.log(account.address);
}
});
// You need to export an object to set up your config
// Go to https://hardhat.org/config/ to learn more
/**
* @type import('hardhat/config').HardhatUserConfig
*/
module.exports = {
solidity: "0.8.4",
defaultNetwork: "rinkeby",
networks: {
rinkeby: {
url: API_URL,
accounts: [PRIVATE_KEY]
}
},
etherscan: {
apiKey: ETHERSCAN_API
}
};
现在,运行以下两个命令:
npx hardhat clean
npx hardhat verify --network rinkeby DEPLOYED_CONTRACT_ADDRESS "BASE_TOKEN_URI"
在我们的例子中,第二条命令看起来像这样:
npx hardhat verify --network rinkeby 0x355638a4eCcb777794257f22f50c289d4189F245 "ipfs://QmZbWNKJPAjxXuNFSEaksCJVd1M6DaKQViJBYPK2BdpDEP/"
现在,如果访问你的合约的Rinkeby Etherscan页面,你应该在合约标签旁边看到一个小的绿色勾。更重要的是,用户现在可以使用Metamask连接到web3,并从Etherscan上调用你的合约的功能:
自己试试吧。
连接你用来部署合约的账户,从etherscan调用withdraw
功能。你应该可以将合约中的0.03个ETH转账到你的钱包里。另外,邀请你的一个朋友连接他们的钱包,通过调用mintNFT
函数来铸造一些NFT。
总结
我们现在有一个已部署的智能合约,可以让用户从我们的合约中铸造NFT。一个明显的下一步是建立一个web3应用程序,让我们的用户可以直接从我们的网站上铸造NFT。这将是另一个教程的主题。
如果你已经走到了这一步,恭喜你!
最终代码库:https://github.com/rounakbanik/nft-collectible-contract
本翻译由 Duet Protocol 赞助支持。
本文参与区块链开发网写作激励计划 ,好文好收益,欢迎正在阅读的你也加入。
- 发表于 2022-08-12 17:01
- 阅读 ( 345 )
- 学分 ( 55 )
- 分类:智能合约