仓库源文站点原文


title: "使用 HardHat 创建 NFT 智能合约" date: 2023-06-08T18:30:00+08:00 tags: ["WEB3"] draft: false

toc: true

引言

最近在学智能合约,本篇文章主要记录如何使用 HardHat 创建 NFT 智能合约。我们还将学习如何使用 Hardhat 写测试合约和部署智能合约。

先决条件

搭建环境

安装 Node.js

首先,我们需要安装 Node.js,参照官网的安装教程即可。

MetaMask

<!--more-->

创建 Polygonscan API 密钥

当我们将我们的合约部署到区块链(主网或测试网)时,部署后验证我们的智能合约代码是一种最佳实践。如果我们的智能合约被验证了,那么智能合约代码将在区块浏览器上可见,用户将能够直接从区块浏览器(如 Polygonscan)与智能合约交互。验证源代码是非常被鼓励的,因为它使我们的项目更透明,用户更有可能与之交互。

使用 HardHat 插件,智能合约可以在部署过程中自动进行验证。为此,我们需要一个 Polygonscan API 密钥。按照以下步骤获取你自己的 API 密钥:

现在你有了一个 API 密钥,这将允许你访问 Polygonscan API 的功能,如合约验证。这个密钥对主网和测试网都是一样的。

创建一个 HardHat 项目

安装 HardHat,运行命令:

npm install -g hardhat

这将全局安装 HardHat,以便我们后来可以使用 npx 命令来创建 HardHat 项目。

现在,我们将使用以下代码创建我们的项目:

mkdir art_gallery # 我将我的项目文件夹命名为 art_gallery,但其他任何名称都可以
cd art_gallery    # 进入目录
npx hardhat

输入最后一个命令后,类似于以下的内容应该出现在你的屏幕上:

这里我选的是 typescript,你可以根据自己的喜好选择。

理解代码

现在让我们打开我们的项目并看看它包含什么。我将使用 VSCode 作为我的编辑器,但你可以自由地使用你感觉舒服的任何其他代码编辑器。

我们得到的是一个非常简单的项目脚手架。所有我们的智能合约、脚本文件和测试脚本都将保存在它们各自的目录(文件夹)中。

hardhat.config.js 文件包含了所有特定于 HardHat 的配置。

在我们开始编写我们的智能合约之前,让我们看一下 hardhat.config.js 文件,这是我们 HardHat 项目的核心。这个文件的默认内容是:

import { HardhatUserConfig } from "hardhat/config";
import "@nomicfoundation/hardhat-toolbox";

const config: HardhatUserConfig = {
  solidity: "0.8.18",
};

export default config;

安装 OpenZeppelin 库

在编写任何程序时,我们总是倾向于使用各种库,这样我们就不必从头开始编写。由于我们将构建一个基于 NFT 的项目,我们将遵循在 EIP-721 中定义的标准。最好的方式是导入 OpenZeppelin 合约库中的 ERC721 合约,并只对我们的项目进行必要的修改。要安装这个包,打开终端并运行命令:

npm install @openzeppelin/contracts

开始我们的智能合约

让我们在 contracts 目录中创建一个名为 Artwork.sol 的新文件。这将是我们的第一个智能合约,它将帮助我们创建 NFTs。

//SPDX-License-Identifier: Unlicense
pragma solidity ^0.8.9;

contract Artwork {}

我们首先定义我们的智能合约的许可证。对于这个教程,我们将其保留为未许可。如果我们不定义许可证,它将在编译时引起警告。pragma 关键字用于定义用于编译代码的 Solidity 版本。

接下来,我们将从我们刚刚安装的 OpenZeppelin 库中导入 ERC721 智能合约。在定义 Solidity 版本的行之后和定义合约之前,导入 ERC721 合约:

import "@openzeppelin/contracts/token/ERC721/ERC721.sol";

如果使用 VSCode,我们需要在 .vscode/settings.json 文件中添加以下配置:

"solidity.remappingsUnix": ["@openzeppelin/=node_modules/@openzeppelin/"]

参考 Source "@openzeppelin/contracts...." not found: File import callback not supported

继承 ERC721 和构造器初始化

对代码做出以下修改:

//SPDX-License-Identifier: Unlicense
pragma solidity ^0.8.9;

import "@openzeppelin/contracts/token/ERC721/ERC721.sol";

contract Artwork is ERC721 {

    constructor(
        string memory name,
        string memory symbol
    ) ERC721(name, symbol) {}

}

这里我们正在做以下几件事:

定义 tokenCounter

NFT 被称为非同质化代币,因为每一个都是独一无二的。使它们独一无二的是赋予它们的代币 id。我们将定义一个名为 tokenCounter 的全局变量,并用它来计算代币 id。它将从零开始,每创建(或"铸造")一个新的 NFT,它就增加 1。在构造器中,tokenCounter 的值被设置为 0。

//SPDX-License-Identifier: Unlicense
pragma solidity ^0.8.9;

import "@openzeppelin/contracts/token/ERC721/ERC721.sol";

contract Artwork is ERC721 {

    uint256 public tokenCounter;

    constructor(
        string memory name,
        string memory symbol
    ) ERC721(name, symbol) {
        tokenCounter = 0;
    }

}

创建 mint 函数

现在我们将定义一个 mint 函数,任何用户都可以调用它来铸造新的 NFT。每个 NFT 都会有一些关联的数据。在我们的情况下,我们使用图像或其他收藏品作为 NFT 的基础,因此图像应该以某种方式存储在智能合约中。由于直接在区块链上存储数据有相关的成本,如果存储整个图像和其他关联数据(元数据),那么在财务上将不可行。所以,我们需要单独托管图像以及包含所有 NFT 详细信息的 JSON 文件。图像和 JSON 文件可以分别使用去中心化(使用 IPFS)或传统方法集中托管。JSON 文件也包含指向图像的链接。一旦托管了 JSON 文件,指向该 JSON 文件的链接就存储在区块链中,作为 tokenURI。URI 代表"通用资源标识符"。以下是集中托管 tokenURI 的一个例子。

有了这个思路,mint 函数就是我们创建与智能合约关联的每个 NFT 的方式:

function mint(string memory _tokenURI) public {
    _safeMint(msg.sender, tokenCounter);
    _setTokenURI(tokenCounter, _tokenURI);

    tokenCounter++;
}

_safeMint 是 OpenZeppelin ERC721 合约中的另一个函数,用于铸造新的 NFT。它需要两个参数:

msg.sender 是一个特殊的关键字,它返回调用智能合约的账户的地址。在这种情况下,它将返回当前调用 mint 函数的账户。因此,调用 mint 函数的账户将作为第一个参数传递,所以铸造的 NFT 将由这个账户拥有。

_setTokenURI() 函数还没有定义,所以暂时忽略它。这个函数将用于设置铸造的 NFT 的 tokenURI。这个函数在 ERC721 库中存在,但在 Solidity 版本 0.8.0 之后已经被废弃,所以我们需要自己实现它。

一旦代币被铸造并设置了其 tokenURI,我们就将 tokenCounter 增加 1,以便下一个铸造的代币有一个新的代币 id。

创建 _setTokenURI() 函数

我们的 NFT 智能合约必须存储所有有效的 tokenId 及其各自的 tokenURI。为此,我们可以使用 Solidity 中的 mapping 数据类型。映射的工作方式类似于 Java 等其他编程语言中的 hashmap。我们可以定义一个从 uint256 数到 string 的映射,这将表明每个 tokenId 都映射到其各自的 tokenURI。在声明 tokenCounter 变量之后,定义映射:

mapping (uint256 => string) private _tokenURIs;

现在让我们编写_setTokenURI 函数:

function _setTokenURI(uint256 _tokenId, string memory _tokenURI) internal virtual {
    require(
        _exists(_tokenId),
        "ERC721Metadata: URI set of nonexistent token"
    );  // Checks if the tokenId exists
    _tokenURIs[_tokenId] = _tokenURI;
}

这里定义了许多新的术语,所以让我们逐一处理:

总结:这个函数首先确保我们试图设置 tokenURI 的 tokenId 已经被铸造。如果是,它将把 tokenURI 添加到映射中,以及相应的 tokenId。

创建 tokenURI() 函数

我们需要创建的最后一个函数是 tokenURI() 函数。它将是一个公共可调用的函数,接受一个 tokenId 作为参数,并返回其相应的 tokenURI。这是一个标准的函数,被 OpenSea 等基于 NFT 的平台调用。像这样的平台使用这个函数返回的 tokenURI 来显示有关 NFT 的各种信息,如其属性和显示图像。

让我们编写 tokenURI 函数:

function tokenURI(uint256 _tokenId) public view virtual override returns(string memory) {
    require(
        _exists(_tokenId),
        "ERC721Metadata: URI set of nonexistent token"
    );
    return _tokenURIs[_tokenId];
}

这个函数首先检查是否铸造了传入的 tokenId。如果已经铸造了代币,它从映射中返回 tokenURI。

将所有功能组合在一起

将所有函数组合在一起,最终的智能合约将如下所示:

//SPDX-License-Identifier: Unlicense
pragma solidity ^0.8.9;

import "@openzeppelin/contracts/token/ERC721/ERC721.sol";

contract Artwork is ERC721 {

    uint256 public tokenCounter;
    mapping (uint256 => string) private _tokenURIs;

    constructor(
        string memory name,
        string memory symbol
    ) ERC721(name, symbol) {
        tokenCounter = 0;
    }

    function mint(string memory _tokenURI) public {
        _safeMint(msg.sender, tokenCounter);
        _setTokenURI(tokenCounter, _tokenURI);

        tokenCounter++;
    }

    function _setTokenURI(uint256 _tokenId, string memory _tokenURI) internal virtual {
        require(
            _exists(_tokenId),
            "ERC721Metadata: URI set of nonexistent token"
        );  // Checks if the tokenId exists
        _tokenURIs[_tokenId] = _tokenURI;
    }

    function tokenURI(uint256 _tokenId) public view virtual override returns(string memory) {
        require(
            _exists(_tokenId),
            "ERC721Metadata: URI set of nonexistent token"
        );
        return _tokenURIs[_tokenId];
    }

}

编译智能合约

现在我们的智能合约已经准备好了,我们必须将其编译。为了使用 HardHat 编译一个智能合约,请运行以下命令:

npx hardhat compile

如果一切顺利,你将会看到「Compilation finished successfully」的信息。如果合约没有成功编译或者出现了错误,你可以尝试再次阅读本教程,找出出错的地方。一些可能出现的错误包括:

测试智能合约

到目前为止,我们已经编写了我们的智能合约并编译了它。然而,一个成功编译的智能合约并不意味着它是正确的!编写测试用例以确保它通过所有预期的使用情况和一些边缘情况是非常重要的。由于一旦智能合约被部署到区块链上就不能被修改,因此测试智能合约变得更加重要。

我们将使用 chai 库来编写我们的测试。如果在创建项目时没有安装这个库,你可以使用命令 npm install --save-dev chai 来安装。

我们将对我们的智能合约进行以下测试:

编写测试用例

在 test 目录下创建一个新的文件,叫做 Artwork.ts。文件名并不重要,但为了保持有序,测试文件的名称应该和被测试的合约有所关联。在这个新文件中,添加以下代码:

import { expect } from "chai";
import { ethers } from "hardhat";

describe("Artwork Smart Contract Tests", function () {
    this.beforeEach(async function () {
        // This is executed before each test
    })

    it("NFT is minted successfully", async function () {

    })

    it("tokenURI is set sucessfully", async function () {

    })
});

注意:与 Truffle 不同,HardHat 不需要单独为测试运行 ganache-cli。Hardhat 有自己的本地测试网,我们可以使用。

部署合约和编写测试

为了部署智能合约,我们首先需要使用 ethers.getContractFactory() 获取对编译好的智能合约的引用,然后我们可以使用 deploy() 方法来部署智能合约并传入参数。我们在 beforeEach() 部分做这个操作。

let artwork;

this.beforeEach(async function() {
    // This is executed before each test
    // Deploying the smart contract
    const Artwork = await ethers.getContractFactory("Artwork");
    artwork = await Artwork.deploy("Artwork Contract", "ART");
})

为了检查 NFT 是否正确地被铸造,我们首先获取 HardHat 创建的一个默认账户。然后我们调用智能合约中的 mint 函数,传入一个随机的 tokenURI。我们在铸造之前和之后检查账户的余额,它们应分别为 0 和 1。如果合约通过了测试,那就意味着 NFT 被正确地铸造了。

it("NFT is minted successfully", async function() {
    [account1] = await ethers.getSigners();

    expect(await artwork.balanceOf(account1.address)).to.equal(0);

    const tokenURI = "https://kongz.herokuapp.com/api/metadata/1"
    const tx = await artwork.connect(account1).mint(tokenURI);

    expect(await artwork.balanceOf(account1.address)).to.equal(1);
})

为了检查 tokenURI 是否被正确设置,我们取两个随机的 tokenURIs 并从不同的账户设置它们。然后我们调用 tokenURI() 函数来获取相应 token 的 tokenURI,然后将它们与传入的参数进行匹配,以确保 tokenURIs 被正确地设置。

it("tokenURI is set sucessfully", async function() {
    [account1, account2] = await ethers.getSigners();

    const tokenURI_1 = "https://kongz.herokuapp.com/api/metadata/1"
    const tokenURI_2 = "https://kongz.herokuapp.com/api/metadata/2"

    const tx1 = await artwork.connect(account1).mint(tokenURI_1);
    const tx2 = await artwork.connect(account2).mint(tokenURI_2);

    expect(await artwork.tokenURI(0)).to.equal(tokenURI_1);
    expect(await artwork.tokenURI(1)).to.equal(tokenURI_2);

})

将所有内容放在一起

最终,在将所有测试用例组合在一起后,Artwork.ts 文件的内容将是:

import { expect } from "chai";
import { ethers } from "hardhat";
import { Artwork } from "../typechain-types";

describe("Artwork Smart Contract Tests", function () {
    let artwork: Artwork;

    this.beforeEach(async function () {
        // This is executed before each test
        // Deploying the smart contract
        const Artwork = await ethers.getContractFactory("Artwork");
        artwork = await Artwork.deploy("Artwork Contract", "ART");
    })

    it("NFT is minted successfully", async function () {
        let account1;
        [account1] = await ethers.getSigners();

        expect(await artwork.balanceOf(account1.address)).to.equal(0);

        const tokenURI = "https://kongz.herokuapp.com/api/metadata/1"
        const tx = await artwork.connect(account1).mint(tokenURI);

        expect(await artwork.balanceOf(account1.address)).to.equal(1);
    })

    it("tokenURI is set successfully", async function () {
        let account1;
        let account2;
        [account1, account2] = await ethers.getSigners();

        const tokenURI_1 = "https://kongz.herokuapp.com/api/metadata/1"
        const tokenURI_2 = "https://kongz.herokuapp.com/api/metadata/2"

        const tx1 = await artwork.connect(account1).mint(tokenURI_1);
        const tx2 = await artwork.connect(account2).mint(tokenURI_2);

        expect(await artwork.tokenURI(0)).to.equal(tokenURI_1);
        expect(await artwork.tokenURI(1)).to.equal(tokenURI_2);

    })
});

您可以使用命令运行测试:

npx hardhat test

部署智能合约

到目前为止,我们已经学会了如何编写智能合约并对它们进行测试。现在我们终于可以开始将我们的智能合约部署到 Mumbai 测试网络了,这样我们就可以向我们的朋友们炫耀我们新学到的技能了😎。

在我们继续并部署我们的智能合约之前,我们将需要两个额外的 npm 包:

设置环境变量

在项目根目录中创建一个名为 .env 的新文件。

.env 文件中创建一个名为 POLYGONSCAN_KEY 的环境变量,将其设置为教程开始时创建的 API 密钥。同时添加另一个条目 PRIVATE_KEY,将其设置为有 MATIC 的 Mumbai 测试网钱包帐户的私钥。

POLYGONSCAN_KEY=xxxx
PRIVATE_KEY=xxxxx

修改配置文件

为了将经过验证的智能合约部署到 Mumbai 测试网络,我们必须在 hardhat.config.js 文件中做一些改动。首先,将这段完整的代码复制粘贴到文件中,然后我们会一步一步地解释这段代码,以理解正在发生的事情:

import { HardhatUserConfig } from "hardhat/config";
import "@nomicfoundation/hardhat-toolbox";
require("dotenv").config();

const config: HardhatUserConfig = {
  solidity: "0.8.18",
  networks: {
    mumbai: {
      url: "https://matic-testnet-archive-rpc.bwarelabs.com",
      accounts: [
       `${process.env.PRIVATE_KEY}`
      ]
    }
  },
  etherscan: {
    apiKey: process.env.POLYGONSCAN_KEY,
  }
};

export default config;

由于我们将把我们的合约部署到 Mumbai 测试网,所以我们在网络部分创建了一个新的网络对象。我们将其命名为 mumbai,并将其 url 设置为 Mumbai 测试网的 RPC url。然后,我们将我们的私钥添加到 accounts 数组中,以便我们可以使用它来部署我们的智能合约。

最后,我们将我们的 API 密钥添加到 etherscan 对象中,以便我们可以在部署智能合约时验证它。

编写部署脚本

scripts 文件夹中创建一个名为 deploy_artwork.ts 的新文件。在这个文件中,我们将编写一个脚本,用于部署我们的智能合约。

import { ethers, run } from "hardhat";

async function main() {
  const ContractFactory = await ethers.getContractFactory("Artwork");
  const contract = await ContractFactory.deploy("Artwork Contract", "ART");


  // Wait for the contract to be mined and get the contract's deployed bytecode
  await contract.deployed();

  console.log("Contract deployed to:", contract.address);

  // wait 1 minute for the contract to be mined
  await new Promise((r) => setTimeout(r, 60000));

  // Verify the contract
  try {
    await run("verify:verify", {
      address: contract.address,
      constructorArguments: ["Artwork Contract", "ART"],
    });
    console.log(`Contract verified successfully.`);
  } catch (error) {
    console.error("Failed to verify contract:", error);
  }
}

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

执行部署脚本:

npx hardhat run scripts/deploy_artwork.ts --network mumbai

解释一下上面的代码:

需要说明的是:

npx hardhat verify --network sepolia <contract address> "Artwork Contract" "ART"

与智能合约互动

如果我们在 Polygonscan 上查看我们的智能合约,你可以看到我们的合约已经通过验证。这在 Contracts 标签页中有一个绿色的对号表示。在 Contracts 标签页中,点击 Write Contract。现在点击「Connect to Web3」并连接你的 Metamask 账户。

我刚才部署的智能合约的地址是:0x7d64B6EDAcE2Cf8FfE57199406861E9fcEeb7364

现在选择 mint 操作并输入你的 tokenURI。我使用 https://kongz.herokuapp.com/api/metadata/5 作为我的 tokenURI。如果你愿意,你可以通过使用 IPFS 来托管你自己的 tokenURI。一旦你输入你的 tokenURI,点击「Write」按钮。这将显示一个 Metamask 弹出窗口。确认交易并等待其被确认。

恭喜你🥳🥳🥳,你的 NFT 已经成功铸造。你可以访问 Opensea Testnet 页面,用钱包登录之后,在「Profile」部分现在你可以查看你的 NFT。可以在这里查看示例。

结论

在本教程中,我们学习了 HardHat 的一些基础知识。我们编写了一个可以用于创建 NFT 的智能合约,为我们的智能合约编写了测试,最后将其部署到 Mumbai 测试网。我们还使用了 HardHat 插件和 Polygonscan API 密钥验证了我们的合约。使用类似的程序,我们可以构建任意数量的 DeFi 项目并部署到任何兼容 EVM 的网络(Ethereum, Polygon, Binance Smart Chain, Avalanche 等)。

参考资料

在学习过程中,我发现以下资料非常有用:

最后

本篇文章大部分内容都翻译自 《Create an NFT smart contract with HardHat》,并且结合我自己的实践做了一些修改,希望对你有所帮助。

本篇教程的代码已经上传到 Github,地址 hi-hardhat