Solidity合约入门教程——基于Hardhat+Mocha+Chai

2,983 阅读4分钟

P.S. 本文基于Hardhat官方教程和个人理解,欢迎交流、指正。

0. 准备

  • 基本的Javascript知识
  • MetaMask账户

1. 创建项目

Step 1: 新建项目文件夹,在项目文件夹中打开控制台

Step 2: yarn init

Step 3: yarn add --dev hardhat

Step 4: yarn hardhat

然后控制台中出现:

$ yarn hardhat
888    888                      888 888               888
888    888                      888 888               888
888    888                      888 888               888
8888888888  8888b.  888d888 .d88888 88888b.   8888b.  888888
888    888     "88b 888P"  d88" 888 888 "88b     "88b 888
888    888 .d888888 888    888  888 888  888 .d888888 888
888    888 888  888 888    Y88b 888 888  888 888  888 Y88b.
888    888 "Y888888 888     "Y88888 888  888 "Y888888  "Y888

👷 Welcome to Hardhat v2.9.9 👷‍

? What do you want to do? …
  Create a JavaScript project
❯ Create a TypeScript project
  Create an empty hardhat.config.js
  Quit

Step 5: 选择Create a TypeScript project

Step 6: 每个选项都按回车

2. 编写合约

创建项目后,项目中会有一个Lock.sol的Solidity文件。可以直接使用该合约并跳过下面本节剩余内容,或者删除Lock.sol,并阅读本节后续内容。

本节我们将编写一个简单的智能合约,创建一个可以转账的代币。该合约将会实现以下的功能:

  • 代币的总供应量不可更改。
  • 合约部署后会,部署合约的账户将拥有所有代币。
  • 任何账户都可以接收该代币。
  • 任何账户只要拥有至少一个代币就可以发起转账。
  • 该代币不可以拆分。你可以转账例如1、3、5、7、37等个数的整数个代币,但不可以转账2.5个代币。

或许你对以太坊的代币标准ERC-20有了解。像DAI和USDC等代币都遵循ERC-20标准,从而可以和支持ERC-20标准等DAPP交互。在这里为了简单起见,我们要创建的代币将不会实现ERC-20标准。

在项目中创建一个contracts文件夹,在该文件夹下创建一个Token.sol文件,将下面代码块中的代码复制到该文件,代码中有详细的注释帮助你理解其作用。

如果你正在使用Visual Studio Code,可以安装Hardhat的VSCode插件来获得语法高亮和编辑提示。

// 指定该合约的对外开放标准。
//SPDX-License-Identifier: UNLICENSED

// Solidity文件的开头必需带有这行编译注释。
// Solidity编译器会将其用来对使用的Solidity的版本进行验证。
pragma solidity ^0.8.9;

contract Token {
    // 用字符串类型的变量存储代币的名称和代号。
    string public name = "My Hardhat Token";
    string public symbol = "MHT";

    // 用一个256位无符号整数类型的变量保存代币的总量。
    uint256 public totalSupply = 1000000;

    // 用一个地址类型变量存储代币拥有者的地址。
    address public owner;

    // 用一个映射类型的变量存储代币持有者的地址和持有的金额。
    mapping(address => uint256) balances;

    // 定义一个转账事件,该事件接收发送方地址、接收方地址和转账金额作为参数。
    event Transfer(address indexed _from, address indexed _to, uint256 _value);

    // 合约初始化时执行constructor内的代码。
    constructor() {
        // 为部署合约的账户设置代币余额为代币总供应量,即部署合约的账户会在初始时拥有所有代币。
        balances[msg.sender] = totalSupply;
        owner = msg.sender;
    }

    /**
     * 定义转账方法
     *
     * 用external修饰符的方法只能在合约外部被调用。
     */
    function transfer(address to, uint256 amount) external {
        // 检查发送方是否有足够的代币,如果不够则进行相应提示。
        // 如果require的第一个参数为false,则该交易会被撤回。
        require(balances[msg.sender] >= amount, "Not enough tokens");

        // 发送方的余额减去转账金额,接收方的余额加上转账金额。
        balances[msg.sender] -= amount;
        balances[to] += amount;

        // 派发一个转账事件。
        emit Transfer(msg.sender, to, amount);
    }

    /**
     * 定义一个获取账户余额的方法
     *
     * view修饰符表示该方法不修改合约的状态,调用这类方法可以不发送交易。
     */
    function balanceOf(address account) external view returns (uint256) {
        return balances[account];
    }
}

*.sol是Solidity文件的后缀,建议Solidity文件名和合约名保持一致。

3. 编译合约

执行yarn hardhat compile。完成后控制台会出现如下提示

yarn hardhat compile
yarn run v1.22.19
warning package.json: No license field
$ /Users/.../hardhat compile
Generating typings for: 1 artifacts in dir: typechain-types for target: ethers-v5
Successfully generated 6 typings!
Compiled 1 Solidity file successfully
✨  Done in 4.74s.

并且项目中会新增artifacts文件夹。

- artifacts
  - build-info
  - contracts
  - Token.sol

4. 测试合约

智能合约涉及到金钱处理,因此在构建智能合约时编写自动化测试是至关重要的。

我们将使用Hardhat提供的本地以太坊网络来测试合约。该网络内置于Hardhat,并且是默认网络,不需要进行设置就可以使用。

我们将在测试中使用ethers.js与合约进行交互,使用Mocha作为测试框架。

4.1 编写第一个测试

在项目根文件夹中新test文件夹,然后在该文件夹中新建一个Token.ts文件。

完整的代码如下,先将其复制到Token.ts中。

import { expect } from "chai";

describe("Token contract", function () {
  it("Deployment should assign the total supply of tokens to the owner", async function () {
    const [owner] = await ethers.getSigners();

    const Token = await ethers.getContractFactory("Token");

    const hardhatToken = await Token.deploy();

    const ownerBalance = await hardhatToken.balanceOf(owner.address);
    
    expect(await hardhatToken.totalSupply()).to.equal(ownerBalance);
  });
});

在控制台中执行yarn hardhat test,控制台应当输出如下内容。

$ yarn hardhat test

  Token contract
    ✓ Deployment should assign the total supply of tokens to the owner (654ms)


  1 passing (663ms)

这表示我们的第一个测试已经通过了。

下面将解释代码的含义。

import { ethers } from "hardhat";

const [owner] = await ethers.getSigners();

ether.js中,一个Signer代表一个以太坊账户。Signer可以用来向合约或其它账户发送交易。通过这行代码,我们从连接着的网络中获取到一系列的账户,并且取出第一个并命名为owner,当前连接的是Hardhat的本地网络。

const Token = await ethers.getContractFactory("Token");

ethers的ContractFactory用来部署合约,返回值Token是一个合约工厂的实例。

使用Typescript可以在编译合约的时候根据合约的名字(不是Solidity文件名)为getContractFactory方法添加类型限制。

const hardhatToken = await Token.deploy();

通过调用getContractFactorydeploy方法部署合约,该方法会返回一个Promise,该Promise满足后会返回一个Contract对象。我们可以这个Contract对象调用合约中的各种方法。

const ownerBalance = await hardhatToken.balanceOf(owner.address);

Token合约部署成功后,我们可以通过hardhatToken这个合约实例调用合约方法。这里我们通过合约方法balanceOf获取owner账户的余额。

合约代码注释中提到过,部署合约的账户将获得所有代币。默认情况下,ContractFactory实例和Contract实例会和第一个signer相关联。这意味着owner账户执行了合约部署,因此这里调用balanceOf方法获取到的数量同时也是代币的总供应量。

import { expect } from "chai";

expect(await hardhatToken.totalSupply()).to.equal(ownerBalance);

我们首先通过hardhatToken.totalSupply()调用了合约中获取总供应量的方法。然后我们希望能检查此时总供应量和ownerBalance两者数值是否相等。

为了实现这项检查,我们使用Chai这一流行的JavaScript断言库配合@nomicfoundation/hardhat-chai-matchers这一插件,从而得到很多适合测试智能合约断言语句。

4.2 使用多个账户进行测试

如果我们要使用除了默认账户以外的账户(ethers中称为Signer)来测试合约,我们可以使用Contract对象的connect方法使合约连接到其它账户,如下所示:

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

describe("Token contract", function () {
  // ...上一个用例...

  it("Should transfer tokens between accounts", async function() {
    const [owner, addr1, addr2] = await ethers.getSigners();

    const Token = await ethers.getContractFactory("Token");

    const hardhatToken = await Token.deploy();

    // 让owner账户向addr1账户转账50个代币
    await hardhatToken.transfer(addr1.address, 50);
    expect(await hardhatToken.balanceOf(addr1.address)).to.equal(50);

    // 让addr1账户向addr2账户转账50个代币
    await hardhatToken.connect(addr1).transfer(addr2.address, 50);
    expect(await hardhatToken.balanceOf(addr2.address)).to.equal(50);
  });
});

4.3 使用fixtrue复用测试中的公共部分

fixture在这里指某些固定的工作,没找到合适的翻译,就先用英文吧。

前面两个测试的准备工作都包含了了合约部署。在更复杂的场景中,测试的准备工作可能包含更多的合约部署以及其它交易的发送。这些重复的准备工作会带来大量的冗余代码,并且多次发送交易会显著地影响测试执行的速度。

我们可以使用fixtures来减少冗余代码并提高测试的执行速度。fixture只会在第一次调用的时候运行。在后续的调用中,Hardhat不会重新运行fixture,而是将网络的状态重置到fixture第一次运行后的状态。

import { loadFixture } from "@nomicfoundation/hardhat-network-helpers";
import { expect } from "chai";

describe("Token contract", function () {
  async function deployTokenFixture() {
    const Token = await ethers.getContractFactory("Token");
    const [owner, addr1, addr2] = await ethers.getSigners();

    const hardhatToken = await Token.deploy();

    await hardhatToken.deployed();

    // 在Fixture中返回后续测试需要用到的数据
    return { Token, hardhatToken, owner, addr1, addr2 };
  }

  it("Should assign the total supply of tokens to the owner", async function () {
    const { hardhatToken, owner } = await loadFixture(deployTokenFixture);

    const ownerBalance = await hardhatToken.balanceOf(owner.address);
    expect(await hardhatToken.totalSupply()).to.equal(ownerBalance);
  });

  it("Should transfer tokens between accounts", async function () {
    const { hardhatToken, owner, addr1, addr2 } = await loadFixture(
      deployTokenFixture
    );

    // 让owner账户向addr1账户转账50个代币
    await expect(
      hardhatToken.transfer(addr1.address, 50)
    ).to.changeTokenBalances(hardhatToken, [owner, addr1], [-50, 50]);

    // 让addr1账户向addr2账户转账50个代币
    // 使用 .connect(signer) 来指定owner以外的账户发送交易
    await expect(
      hardhatToken.connect(addr1).transfer(addr2.address, 50)
    ).to.changeTokenBalances(hardhatToken, [addr1, addr2], [-50, 50]);
  });
});

上面代码中,我们创建了deployTokenFixture来执行测试中公共的准备工作,并返回后续测试所需的数据。接着我们在测试中用loadFixture运行fixture并得到fixture返回的数据。得益于Hardhat的快照功能,loadFixture会在第一次调用的时候实际运行,后续的其它测试能直接获得fixture返回的数据。

4.4 全面的测试

经过前面的事件,我们已经有了测试合约的基础知识。下面代码是对Token合约的较为全面的测试。

// Hardhat会运行’test/’文件夹下的所有js/ts文件,所以你还可以按照自己的想法添加其它测试文件。

// 我们通常使用Mocha和Chai来编写Hardhat测试

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

import { loadFixture } from "@nomicfoundation/hardhat-network-helpers";

// `describe`是Mocha中的一个方法,我们可以通过这个方法组织测试。
// 对多个测试进行组织能够便于debug。
// 所有Mocha方法都是全局可用的。

// `describe`接收你的测试套件的一个部分的名称,和一个回调。
// 该回调必须定义该部分的测试。
// 该回调不能是一个异步函数。
describe("Token contract", function () {
	async function deployTokenFixture() {
		const Token = await ethers.getContractFactory("Token");

		const [owner, addr1, addr2] = await ethers.getSigners();

		const hardhatToken = await Token.deploy();

		await hardhatToken.deployed();

		return { Token, hardhatToken, owner, addr1, addr2 };
	}

	// You can nest describe calls to create subsections.
	// 可以在'describe'中嵌套'describe'
	describe("Deployment", function () {
		// `it`是另一个Mocha中的方法。我们可以使用该方法定义测试。
		// 该方法接受测试名称和一个回调。
		it("Should set the right owner", async function () {
			// We use loadFixture to setup our environment, and then assert that
			// things went well
			const { hardhatToken, owner } = await loadFixture(
				deployTokenFixture
			);

			// `expect`接收一个值并将其包装在一个断言对象中。这些对象有很多实用的方法来进行断言。

			// 当前测试期望合约中存储的owner变量的值为owner账户的地址
			expect(await hardhatToken.owner()).to.equal(owner.address);
		});

		it("Should assign the total supply of tokens to the owner", async function () {
			const { hardhatToken, owner } = await loadFixture(
				deployTokenFixture
			);
			const ownerBalance = await hardhatToken.balanceOf(owner.address);
			expect(await hardhatToken.totalSupply()).to.equal(ownerBalance);
		});
	});

	describe("Transactions", function () {
		it("Should transfer tokens between accounts", async function () {
			const { hardhatToken, owner, addr1, addr2 } = await loadFixture(
				deployTokenFixture
			);

			// 让owner账户向addr1账户转账50个代币
			await expect(
				hardhatToken.transfer(addr1.address, 50)
			).to.changeTokenBalances(hardhatToken, [owner, addr1], [-50, 50]);

			// 让addr1账户向addr2账户转账50个代币
			// 使用 .connect(signer) 来指定owner以外的账户发送交易
			await expect(
				hardhatToken.connect(addr1).transfer(addr2.address, 50)
			).to.changeTokenBalances(hardhatToken, [addr1, addr2], [-50, 50]);
		});

		it("should emit Transfer events", async function () {
			const { hardhatToken, owner, addr1, addr2 } = await loadFixture(
				deployTokenFixture
			);

			// 让owner账户向addr1账户转账50个代币
			await expect(hardhatToken.transfer(addr1.address, 50))
				.to.emit(hardhatToken, "Transfer")
				.withArgs(owner.address, addr1.address, 50);

			// 让addr1账户向addr2账户转账50个代币
			// 使用 .connect(signer) 来指定owner以外的账户发送交易
			await expect(
				hardhatToken.connect(addr1).transfer(addr2.address, 50)
			)
				.to.emit(hardhatToken, "Transfer")
				.withArgs(addr1.address, addr2.address, 50);
		});

		it("Should fail if sender doesn't have enough tokens", async function () {
			const { hardhatToken, owner, addr1 } = await loadFixture(
				deployTokenFixture
			);
			const initialOwnerBalance = await hardhatToken.balanceOf(
				owner.address
			);

			// 尝试让没有代币的账户addr1向owner账户发送一个代币
			// 合约中的require语句的第一个参数会为false,因此我们期望测试的结果是回退这次交易
			await expect(
				hardhatToken.connect(addr1).transfer(owner.address, 1)
			).to.be.revertedWith("Not enough tokens");

			// 同时我们还期望发送失败后不改变owner账户的余额
			expect(await hardhatToken.balanceOf(owner.address)).to.equal(
				initialOwnerBalance
			);
		});
	});
});

执行yarn hardhat test运行测试,控制台应当有如下输出:

$ yarn hardhat test

  Token contract
    Deployment
      ✓ Should set the right owner
      ✓ Should assign the total supply of tokens to the owner
    Transactions
      ✓ Should transfer tokens between accounts (199ms)
      ✓ Should fail if sender doesn’t have enough tokens
      ✓ Should update balances after transfers (111ms)


  5 passing (1s)

值得一提的是,每次运行测试的时候,如果合约文件有改变,Hardhat会重新编译合约。

5. 在Hardhat中进行调试

5.1 Hardhat网络

Hardhat内置了Hardhat网络,这是一个为开发而设计的本地以太坊网络。我们可以在这个本地网络上部署合约、运行测试和调试代码,所有这些都是在你的本地机器上运行。这是Hardhat默认连接的网络,所以你不需要设置任何东西这个网络就可以运行。本文大部分内容就是在Hardhat本地网络上进行的。

5.2 console.log

当我们在Hardhat网络上调用合约或进行测试的时候,我们可以在 Solidity 代码中调用console.log()来打印日志信息和合约中的变量。为了使用console.log(),我们需要在合约代码中引入hardhat/console.sol,如下:

import "hardhat/console.sol";

接着我们可以在合约中使用console.log了,比如我们可以在合约的transfer方法中打印交易信息:

function transfer(address to, uint256 amount) external {
    require(balances[msg.sender] >= amount, "Not enough tokens");

    console.log(
        "Transferring from %s to %s %s tokens",
        msg.sender,
        to,
        amount
    );

    balances[msg.sender] -= amount;
    balances[to] += amount;

    emit Transfer(msg.sender, to, amount);
}

我们运行测试就能看到打印出来的信息了,如下:

$ yarn hardhat test

  Token contract
    Deployment
      ✓ Should set the right owner
      ✓ Should assign the total supply of tokens to the owner
    Transactions
Transferring from 0xf39fd6e51aad88f6f4ce6ab8827279cfffb92266 to 0x70997970c51812dc3a010c7d01b50e0d17dc79c8 50 tokens
Transferring from 0x70997970c51812dc3a010c7d01b50e0d17dc79c8 to 0x3c44cdddb6a900fa2b585dd299e03d12fa4293bc 50 tokens
      ✓ Should transfer tokens between accounts (373ms)
      ✓ Should fail if sender doesn’t have enough tokens
Transferring from 0xf39fd6e51aad88f6f4ce6ab8827279cfffb92266 to 0x70997970c51812dc3a010c7d01b50e0d17dc79c8 50 tokens
Transferring from 0x70997970c51812dc3a010c7d01b50e0d17dc79c8 to 0x3c44cdddb6a900fa2b585dd299e03d12fa4293bc 50 tokens
      ✓ Should update balances after transfers (187ms)


  5 passing (2s)

6. 部署合约

一旦你准备好与其他人分享你的dApp,你可能想把它部署到一个实时网络上让别人访问。

6.1 在本地网络部署合约

以太坊的 "主网 "网络与真钱打交道,但也有其它独立于主网的 "测试网 "并不涉及真钱。这些测试网提供了与主网相差无几的环境,很好地模仿了主网上的场景,而没有把真金白银置于危险之中,以太坊有几个测试网,比如Rinkeby、Ropsten、Kovan、Goerli和Sepolia,其中前三个测试网都已经被废弃。建议将合约部署到Goerli测试网。

涉及到的主要概念有SignerContractFactoryContract,这些我们在测试部分解释过。与测试相比,没有什么其它的新东西了,因为当我们测试合约的时候,实际上是在在我们的开发网络上进行部署。在代码层面,部署到测试网与部署到主网是一样的,唯一的区别是部署的时候你连接的是哪个网络。这使得部署合约和测试合约的代码非常相似,甚至是可以说是相同的。下面我们会看到使用ethers.js部署合同的代码是什么样子的。

在项目根目录下创建scripts文件,在该文件夹下创建deploy.ts文件,并粘贴以下内容。

async function main() {
  const [deployer] = await ethers.getSigners();

  console.log("Deploying contracts with the account:", deployer.address);

  console.log("Account balance:", (await deployer.getBalance()).toString());

  const Token = await ethers.getContractFactory("Token");
  const token = await Token.deploy();

  console.log("Token address:", token.address);
}

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

为了告诉Hardhat我们需要连接到哪个网络部署合约,我们可以在运行任务(task)的时候添加--network参数,如下:

yarn hardhat run scripts/deploy.ts --network <network-name>

在我们目前的配置下,如果没有--network参数去运行上面的命令,会导致命令运行在Hardhat本地网络上。在这种情况下,当Hardhat结束运行时,Hardhat本地网络上的数据会被丢弃,但这种方式对测试我们的部署代码是否有效仍然是有用的。

执行yarn hardhat run scripts/deploy.ts后,控制台的输出应当如下,只是部署者地址和合约地址会有所不同:

$ yarn hardhat run scripts/deploy.ts
Deploying contracts with the account: 0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266
Account balance: 10000000000000000000000
Token address: 0x5FbDB2315678afecb367f032d93F642f64180aa3

6.2 在测试网上部署合约

为了将合约部署到线上,如主网或任何测试网,你需要在hardhat.config.ts文件中添加一个网络条目。在这个例子中我们将使用Goerli测试网,但你也可以类似地使用其它网络。代码如下:

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

// 到https://www.alchemyapi.io注册账户,然后在dashboard中创建一个app就能获得api key,
// 将ALCHEMY_API_KEY的值替换为你的key。
const ALCHEMY_API_KEY = "YOUR ALCHEMY API KEY";

// 将GOERLI_PRIVATE_KEY替换为有Goerli ETH的账户的私钥
// 注意:不要在测试账户中转入真正的ETH!!!
const GOERLI_PRIVATE_KEY = "YOUR GOERLI PRIVATE KEY";

const config: HardhatUserConfig = {
    solidity: "0.8.9",
	networks: {
		goerli: {
			url: `https://eth-goerli.alchemyapi.io/v2/${ALCHEMY_API_KEY}`,
			accounts: [GOERLI_PRIVATE_KEY],
		},
	},
};

export default config;

为了在测试网上进行部署,我们需要一些测试币,可以通过Alchemy水龙头获取。

执行yarn hardhat run scripts/deploy.ts --network goerli,部署成功后我们就可以在控制台看到合约地址。等Goerli的区块浏览器同步后就可以在上面查询我们部署的合约的详情了。

7. 项目模板

有空再补充。