基于 Solidity、Hardhat、OpenZeppelin 迈向 Web3.0

Solidity 是一款以由以太坊ETH,Ethereum)开源社区推出的面向对象静态程序设计语言,主要用于在 Web 3.0 世界创建智能合约,其语法特性受到了 C++、Python、JavaScript 等编程语言的影响。支持继承、库、复杂的用户自定义类型以及其它特性。官方推荐在生产环境撰写以太坊智能合约的时候,总是使用最新的 Solidity 版本,从而获得安全修复以及各种新特性,本篇文章撰写时 Solidity 最新的生产环境版本为 v0.8.24

除了 Solidity 的各种常用语言特性之外,还会介绍一系列 Web 3.0 开发过程当中,所经常使用的第三方开源项目。其中 Hardhat 是一个用于编译、部署、测试、调试以太坊应用的开发环境,而 Ganache 则是一款用于开发测试 dApps(Decentralized Applications)的本地区块链应用。除此之外,OpenZeppelinContract 则是一款用于开发安全智能合约的库,提供有 ERC20ERC721 的标准实现,以及灵活的的权限方案,乃至于各种常用的工具组件。

Web 3.0 简介

Web 3.0 术语 解释
区块 区块包含有大量捆绑的交易,以其作为最小单位在所有节点当中进行分发,如果两个交易相互矛盾,那么排在第二位的交易会被拒绝,不会成为区块的一部分。
区块链 区块链就是指区块按照时间形成的线性序列,区块每间隔一段时间就会被添加到链上面,其本质上就类似于一个公共的事务型数据库。
以太坊虚拟机 以太坊虚拟机(EVM,Ethereum Virtual Machine)是以太坊智能合约的运行环境。
以太坊账户 以太坊账户主要分为两种:外部账户(由公私钥对控制,地址由公钥确定)、合约账户(由与账户一起存储的代码控制,地址在合约创建时被确定)。
以太坊账户余额 以太坊账户余额的最小单位是 Wei\(1 ETH = 10^{18} wei\))),余额会因为发生以太币的交易而改变。
交易 交易可以视为帐户之间相互发送的消息,每笔交易都会消耗一定数量的 Gas(由交易的发起人支付)。

构建 Hardhat 环境

npm 安装 Hardhat

Hardhat 是一款编译、部署、测试和调试以太坊应用的开发工具,可以用于实现智能合约与 dApps 开发过程当中的自动化任务,但是 Hardhat 最核心的地方依然是编译、运行、测试智能合约。

1
npm install --save-dev hardhat

注意:Hardhat 需要运行在 NodeJS 基础之上,所以在安装 Hardhat 之前需要先行安装 NodeJS,并且将安装目录填写至 PATH 环境变量当中。

通过上面的语句,可以在一个 npm 工程当中快速的安装 Hardhat,然后在工程目录里执行 npx hardhat,就可以快速查看当前可用的命令任务

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
λ npx hardhat

Hardhat version 2.20.1

Usage: hardhat [GLOBAL OPTIONS] [SCOPE] <TASK> [TASK OPTIONS]

GLOBAL OPTIONS:

--config A Hardhat config file.
--emoji Use emoji in messages.
--flamegraph Generate a flamegraph of your Hardhat tasks
--help Shows this message, or a task's help if its name is provided
--max-memory The maximum amount of memory that Hardhat can use.
--network The network to connect to.
--show-stack-traces Show stack traces (always enabled on CI servers).
--tsconfig A TypeScript config file.
--typecheck Enable TypeScript type-checking of your scripts/tests
--verbose Enables Hardhat verbose logging
--version Shows hardhat's version.


AVAILABLE TASKS:

check Check whatever you need
clean Clears the cache and deletes all artifacts
compile Compiles the entire project, building all artifacts
console Opens a hardhat console
coverage Generates a code coverage report for tests
flatten Flattens and prints contracts and their dependencies. If no file is passed, all the contracts in the project will be flattened.
gas-reporter:merge
help Prints this message
node Starts a JSON-RPC server on top of Hardhat Network
run Runs a user-defined script after compiling the project
test Runs mocha tests
typechain Generate Typechain typings for compiled contracts
verify Verifies a contract on Etherscan or Sourcify


AVAILABLE TASK SCOPES:

vars Manage your configuration variables

To get help for a specific task run: npx hardhat help [SCOPE] <TASK>

初始化 Hardhat 工程

通过运行 npx hardhat init 可以初始化出一个基本的 Hardhat 工程目录结构:

  • contracts 目录:用于存放 .sol 智能合约
  • scripts 目录:用于存放任务脚本。
  • test 目录:用于存放测试文件。
  • hardhat.config.js 文件:Hardhat 配置文件。

编译智能合约

在工程目录运行 npx hardhat compile 命令,可以编译 contracts 目录下的智能合约(例如该工程当中的 contracts/Lock.sol 文件):

1
2
3
4
λ npx hardhat compile

Downloading compiler 0.8.24
Compiled 1 Solidity file successfully (evm target: paris).

测试智能合约

然后再运行 npx hardhat test 命令,可以执行 contracts 目录下的测试脚本文件(例如本工程当中的 test/Lock.js 文件):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
λ npx hardhat test

Lock
Deployment
√ Should set the right unlockTime (11217ms)
√ Should set the right owner
√ Should receive and store the funds to lock
√ Should fail if the unlockTime is not in the future (141ms)
Withdrawals
Validations
√ Should revert with the right error if called too soon
√ Should revert with the right error if called from another account (39ms)
√ Shouldn't fail if the unlockTime has arrived and the owner calls it (42ms)
Events
√ Should emit an event on withdrawals
Transfers
√ Should transfer the funds to the owner (88ms)

9 passing (12s)

部署智能合约

继续运行 npx hardhat run scripts/deploy.js 命令,就可以执行 scripts 目录下的 Hardhat 任务脚本(例如本工程当中的 scripts/deploy.js 文件),此时 Hardhat 会将智能合约部署到执行命令时,自动启动的 Hardhat Network 本地测试网络服务当中:

1
2
3
λ npx hardhat run scripts/deploy.js

Lock with 0.001ETH and unlock timestamp 1708929986 deployed to 0x5FbDB2315678afecb367f032d93F642f64180aa3

启动 Hardhat Network

除此之外,也可以通过手动运行 npx hardhat node 命令,启动该本地测试网络服务的同时,还会生成一系列测试用账户:

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
λ npx hardhat node

Started HTTP and WebSocket JSON-RPC server at http://127.0.0.1:8545/

Accounts
========

WARNING: These accounts, and their private keys, are publicly known.
Any funds sent to them on Mainnet or any other live network WILL BE LOST.

Account #0: 0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266 (10000 ETH)
Private Key: 0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80

Account #1: 0x70997970C51812dc3A010C7d01b50e0d17dc79C8 (10000 ETH)
Private Key: 0x59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d

Account #2: 0x3C44CdDdB6a900fa2b585dd299e03d12FA4293BC (10000 ETH)
Private Key: 0x5de4111afa1a4b94908f83103eb1f1706367c2e68ca870fc3fb9a804cdab365a

Account #3: 0x90F79bf6EB2c4f870365E785982E1f101E93b906 (10000 ETH)
Private Key: 0x7c852118294e51e653712a81e05800f419141751be58f605c371e15141b007a6

Account #4: 0x15d34AAf54267DB7D7c367839AAf71A00a2C6A65 (10000 ETH)
Private Key: 0x47e179ec197488593b187f80a00eb0da91f1b9d0b13f8733639f19c30a34926a

Account #5: 0x9965507D1a55bcC2695C58ba16FB37d819B0A4dc (10000 ETH)
Private Key: 0x8b3a350cf5c34c9194ca85829a2df0ec3153be0318b5e2d3348e872092edffba

..... ..... ..... ..... ..... .....
..... ..... ..... ..... ..... .....

WARNING: These accounts, and their private keys, are publicly known.
Any funds sent to them on Mainnet or any other live network WILL BE LOST.

注意:Hardhat 内置的 Hardhat Network 是一个为开发而设计的本地以太坊网络

连接 Hardhat Network

通过 npx hardhat node 启动 Hardhat Network 本地测试网络服务之后,就会向外暴露一个 JSON-RPC 服务接口 http://127.0.0.1:8545/,把区块链钱包等应用连接至该接口就可以使用。此时如果需要将上面的 Lock.sol 智能合约,部署到上述这个已经启动了的测试网络,则需要再添加上一个 --network localhost 参数:

1
2
3
λ npx hardhat run scripts/deploy.js --network localhost

Lock with 0.001ETH and unlock timestamp 1708931638 deployed to 0x5FbDB2315678afecb367f032d93F642f64180aa3

日志打印 console.log()

通过向 Hardhat 项目当中的 .sol 智能合约里引入 console.sol,就可以愉快的在项目当中使用 console.log(); 日志打印方法,从而能够更加便捷的在 Hardhat Network 控制台查看到智能合约打印的调试信息:

1
import "hardhat/console.sol";

引入以太坊 Web3.js

web3.js 是以太坊官方开源社区提供的一个 JavaScript 库,允许通过 HTTP、IPC、WebSocket 与本地或者远程的以太坊 EVM 区块链节点进行各种交互,可以通过下面的命令将其安装在 Hardhat 工程当中:

1
npm install --save-dev web3

除此之外,也可以通过在 Hardhat 项目当中安装插件的形式,将 Web3.js 无缝整合到到工程当中:

1
npm install --save-dev @nomicfoundation/hardhat-web3-v4

通过在 Hardhat 当中编写 script 脚本,就可以借助 Web3.js 与 Hardhat 本地的测试网络进行交互,具体步骤请参考下面的示例代码:

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
75
const { Web3 } = require("web3");

async function main() {
const web3 = new Web3("http://127.0.0.1:8545/");

/** =============== 本地 Hardhat 测试网络账户地址 =============== */
const TestAccounts = [
{
ID: 0,
address: "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266",
privateKey: "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80",
},
{
ID: 1,
address: "0x70997970C51812dc3A010C7d01b50e0d17dc79C8",
privateKey: "0x59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d",
},
{
ID: 2,
address: "0x3C44CdDdB6a900fa2b585dd299e03d12FA4293BC",
privateKey: "0x5de4111afa1a4b94908f83103eb1f1706367c2e68ca870fc3fb9a804cdab365a",
},
];

/** =============== 查询区块链信息 =============== */
const BlockNumber = await web3.eth.getBlockNumber();
console.log("当前区块号: ", BlockNumber);

const Balance = await web3.eth.getBalance(TestAccounts[0].address);
console.log("指定账户地址的余额: ", Balance);

const ChainId = await web3.eth.getChainId();
console.log("当前区块链 ID: ", ChainId);

const TransactionCount = await web3.eth.getTransactionCount(TestAccounts[0].address);
console.log("指定账户的交易数量: ", TransactionCount);

const GasPrice = await web3.eth.getGasPrice();
console.log("当前区块链网络的 Gas 价格: ", GasPrice);

/** =============== 设置 Web3.js 本地钱包 =============== */
const WalletAccounts = await web3.eth.accounts.wallet.create(3);
console.log("随机生成 3 个钱包账户: ", WalletAccounts);

const PrivateWalletAccount = await web3.eth.accounts.wallet.add(TestAccounts[0].privateKey);
console.log("通过私钥生成的钱包账户地址: ", PrivateWalletAccount[0].address);
console.log("通过私钥生成的钱包账户私钥: ", PrivateWalletAccount[0].privateKey);

/** =============== 向 Hardhat 测试网络发起交易 =============== */
const TX = {
from: TestAccounts[0].address,
to: TestAccounts[1].address,
value: web3.utils.toWei("0.000001", "ether"),
};
const txReceipt = await web3.eth.sendTransaction(TX);
console.log("转账交易哈希:", txReceipt.transactionHash);

/** =============== 智能合约 =============== */
const AccessControlAddress = "0x322813Fd9A801c5507c9de605d63CEA4f2CE6c44";
const AccessControlABI = require("../artifacts/contracts/TestAccessControl.sol/TestAccessControl.json").abi;
const AccessControlContract = new web3.eth.Contract(AccessControlABI, AccessControlAddress);

/** =============== 读取智能合约方法的返回值 =============== */
const ReturnValue = await AccessControlContract.methods.securedFunction().call();
console.log("智能合约调用返回值 :", ReturnValue);

/** =============== 向智能合约方法写入参数 =============== */
const ReceiptTX = await AccessControlContract.methods.addToRole(TestAccounts[1].address).send({ from: TestAccounts[0].address });
console.log("智能合约调用交易哈希:", ReceiptTX.transactionHash);
}

main().catch((error) => {
console.error(error);
process.exitCode = 1;
});

智能合约基本结构

Solidity 将 Web 3.0 当中的智能合约视为面向对象编程当中的,每一份智能合约可以包含状态变量函数函数修饰器事件错误结构体类型枚举类型 的声明,并且智能合约之间也可以相互进行继承。

许可标识 & 编译指示

  1. 第 1 行的 // SPDX-License-Identifier: MIT 称为 SPDX 许可标识符,用于声明当前 Solidity 源代码基于 MIT 开源协议编写。
  2. 第 2 行的 pragma solidity >=0.8.24 <0.9.0; 称为版本编译指示,用于声明当前代码所要使用的 Solidity 编译器版本(大于或等于 0.8.24 但是低于 0.9.0 的版本)。
1
2
3
4
5
6
// SPDX-License-Identifier: MIT
pragma solidity >=0.8.24 <0.9.0;

contract Test {
// 智能合约内容
}

注意:访问 Solidity 智能合约当中的状态变量(例如上面的 data 状态变量),通常不需要添加 this 关键字,通过变量名称就可以直接进行访问。

状态变量

状态变量是指其值被永久地存储在合约存储中的变量。

1
2
3
4
5
6
// SPDX-License-Identifier: MIT
pragma solidity >=0.8.24 <0.9.0;

contract Test {
uint data; // 状态变量
}

函数

函数 function 用于接受参数并且返回变量,即可以在智能合约 contract 的内部定义,也可以在智能合约的外部定义。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// SPDX-License-Identifier: MIT
pragma solidity >=0.8.24 <0.9.0;

contract Test {
uint data; // 声明一个无符号整型的状态变量

/* 设置状态变量函数 */
function set(uint value) public {
data = value;
}

/* 获取状态变量函数 */
function get() public view returns (uint) {
return data;
}
}

函数修饰器

函数修饰器 modifier 可以用于以声明的方式修改函数的语义。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// SPDX-License-Identifier: MIT
pragma solidity >=0.8.24 <0.9.0;

contract Test {
address public user;

/* 定义函数修饰器 */
modifier onlyUser() {
require(
msg.sender == user,
"Only user can invoke this."
);
_;
}

/* 应用函数修饰器 */
function remove() public view onlyUser {
// ... ... ... ... ...
}
}

注意:修饰器与函数一样也可以被重载

事件

事件 event 可以用于方便的调用以太坊虚拟机 EVM 的日志功能。

1
2
3
4
5
6
7
8
9
10
// SPDX-License-Identifier: MIT
pragma solidity >=0.8.24 <0.9.0;

contract Test {
event myEvent(address user, uint money); // 声明事件

function triggerEvent() public payable {
emit myEvent(msg.sender, msg.value); // 触发事件
}
}

结构体类型

结构体类型 struct 是一种可以把多个具有关联关系的变量,组合在一起的自定义数据类型。当声明并且定义好一个结构体变量之后,就可以通过成员访问操作符 . 访问结构体的成员:

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
// SPDX-License-Identifier: MIT
pragma solidity >=0.8.24 <0.9.0;

contract MyContract {
/* 声明结构体类型 */
struct Person {
string name;
uint age;
bool isStudent;
}

Person public person; // 定义结构体变量

/* 初始化结构体成员 */
constructor() {
person = Person("Hank", 18, true);
}

/* 获取并且返回结构体的 name 成员 */
function getPersonName() public view returns (string memory) {
return person.name; // 使用成员访问操作符(.)来访问结构体成员
}

/* 获取并且返回结构体的 age 成员 */
function getPersonAge() public view returns (uint) {
return person.age; // 使用成员访问操作符(.)来访问结构体成员
}

/* 获取并且返回结构体的 isStudent 成员 */
function isPersonStudent() public view returns (bool) {
return person.isStudent; // 使用成员访问操作符(.)来访问结构体成员
}
}

枚举类型

枚举可用来创建由一定数量的'常量值'构成的自定义类型

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// SPDX-License-Identifier: MIT
pragma solidity >=0.8.24 <0.9.0;

contract MyEnumContract {
/* 声明枚举类型 */
enum Color {
Red,
Green,
Blue
}

Color public favoriteColor; // 定义枚举类型变量

/* 初始化枚举类型变量 */
constructor() {
favoriteColor = Color.Blue; // 设置favoriteColor为Green
}

/* 返回枚举类型变量 */
function getFavoriteColor() public view returns (Color) {
return favoriteColor; // 返回favoriteColor枚举变量的当前值
}
}

错误

错误 error 可以用于为系统异常定义描述性的名称和信息,其 Gas 开销要比使用字符串更加便宜。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// SPDX-License-Identifier: MIT
pragma solidity >=0.8.24 <0.9.0;

/* 没有足够的资金用于转账,需要的资金为 requested`,但是可用的只有 available */
error NotEnough(uint requested, uint available); // 定义错误

contract Token {
mapping(address => uint) balances;

function transfer(address to, uint amount) public {
uint balance = balances[msg.sender];
if (balance < amount)
revert NotEnough(amount, balance); // 回滚函数,并且抛出错误
// ... ... ... ... ...
}
}

注释语句

Solidity 支持 C 语言风格的单行与多行注释:

1
2
3
4
5
6
// 这是一条       单行注释

/*
这是一条
多行注释
*/

除此之外,Solidity 还支持 NatSpec 风格的注释,也就是 ////** ... */,主要用于函数声明和定义相关的语句上面:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// SPDX-License-Identifier: MIT
pragma solidity >=0.8.24 <0.9.0;

/// @title 一个简单的状态变量存储示例
contract SimpleStorage {
uint storedData;

/// 存储参数 x 的值
/// @param 参数 x 指待存储的值
/// @dev 将数字存储在状态变量 storedData 当中
function set(uint x) public {
storedData = x;
}

/// 获取存储的状态变量
/// @dev 获取状态变量 storedData 的值
/// @return 状态变量存储的值
function get() public view returns (uint) {
return storedData;
}
}

变量作用域

Solidity 当中的变量按照作用域可以划分为状态变量(State Variable)、局部变量(Local Variable)、全局变量(Global Variable)三种类型:

状态变量是用于将数据保存在区块链上的变量,智能合约当中的函数都可以进行访问,所消耗的 Gas 比较高:

1
2
3
4
5
6
7
8
// SPDX-License-Identifier: MIT
pragma solidity >=0.4.16 <0.9.0;

contract Test {
uint public x = 1;
uint public y = 2;
uint public z = x + y;
}

局部变量只在函数执行期间有效,存储在内存当中,不会上链,所消耗的 Gas 比较低:

1
2
3
4
5
6
7
8
9
10
11
// SPDX-License-Identifier: MIT
pragma solidity >=0.4.16 <0.9.0;

contract Test {
function add() external pure returns (uint256) {
uint256 x = 1;
uint256 y = 2;
uint256 z = x + y;
return (z);
}
}

全局变量基本都是 Solidity 预留的关键字,可以在函数当中不声明直接进行使用,具体请叁考官方文档中的《单位和全局变量》

1
2
3
4
5
6
7
8
9
10
11
12
// SPDX-License-Identifier: MIT
pragma solidity >=0.4.16 <0.9.0;

contract Test {
function global() external view returns(address, uint, bytes memory){
address mySender = msg.sender; // 请求发起地址
uint256 myNumber = block.number; // 当前区块号
bytes memory myData = msg.data; // 完整 calldata

return (mySender, myNumber, myData);
}
}

常量 constant

常量 constant 必须在声明的同时进行初始化,后续不能再进行修改。

1
2
3
4
5
6
7
// SPDX-License-Identifier: MIT
pragma solidity >=0.8.24 <0.9.0;

contract Test {
uint public constant year = 2024;
uint public constant month = 2;
}

不变量 immutable

不变量 immutable 可以在声明的时候,或者构造函数(非普通函数)当中进行初始化,使用起来将会更加便利。

1
2
3
4
5
6
7
8
9
10
11
12
// SPDX-License-Identifier: MIT
pragma solidity >=0.8.24 <0.9.0;

contract Test {
uint public immutable year;
uint public immutable month = 2;

/* 构造函数 */
constructor() {
year = 2024;
}
}

条件判断 & 循环控制

Solidity 同样提供有 if else 条件判断语句和 forwhiledo while 循环控制语句,以及 continuebreak 关键字和三元操作符。

if else 判断

1
2
3
4
5
6
7
8
9
10
11
12
13
// SPDX-License-Identifier: MIT
pragma solidity >=0.8.24 <0.9.0;

contract Test {
function testIfElse(uint256 number) public pure returns (bool) {
/* if else 判断 */
if (number == 0) {
return (true);
} else {
return (false);
}
}
}

for 循环

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// SPDX-License-Identifier: MIT
pragma solidity >=0.8.24 <0.9.0;

contract Test {
function testFor() public pure returns (uint256) {
uint256 sum = 0;

/* for 循环 */
for (uint256 index = 0; index < 10; index++) {
sum += index;
}

return (sum);
}
}

while 循环

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// SPDX-License-Identifier: MIT
pragma solidity >=0.8.24 <0.9.0;

contract Test {
function testWhile() public pure returns (uint256) {
uint256 sum = 0;
uint256 index = 0;

/* while 循环 */
while (index < 10) {
sum += index;
index++;
}

return (sum);
}
}

do while 循环

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// SPDX-License-Identifier: MIT
pragma solidity >=0.8.24 <0.9.0;

contract Test {
function testDoWhile() public pure returns (uint256) {
uint256 sum = 0;
uint256 index = 0;

/* do while 循环 */
do {
sum += index;
index++;
} while (index < 10);

return (sum);
}
}

三元操作符

1
2
3
4
5
6
7
8
9
// SPDX-License-Identifier: MIT
pragma solidity >=0.8.24 <0.9.0;

contract Test {
function testTernaryOperator(uint256 x, uint256 y) public pure returns (uint256) {
/* 三元操作符 */
return x >= y ? x : y; // 返回参数 x 和 y 的最大值
}
}

数值类型 Value Type

对数值类型的变量进行赋值时候,直接传递的是数值本身。

布尔类型 bool

Solidity 当中布尔类型 bool 可取的值只有 truefalse 两个:

1
2
3
4
5
6
7
// SPDX-License-Identifier: MIT
pragma solidity >=0.8.24 <0.9.0;

contract Test {
bool truly = true;
bool falsely = false;
}

整型 int/uint

  • 关键字intuint 分别表示有符号无符号的整型变量(uintint 本质上分别是 uint256int256 的别名)。
  • 关键字 int8int256 以及 uint8uint256 可以用于表示从 8 位到 256 位,以八位作为步长递增的有符号或者无符号整型变量。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// SPDX-License-Identifier: MIT
pragma solidity >=0.8.24 <0.9.0;

contract Test {
int intValue = 2024;
uint uintValue = 2024;

int8 int8Value = 99;
int32 int32Value = 2024;
int256 int256Value = 2024;

uint8 uint8Value = 99;
uint32 uint32Value = 2024;
uint256 uint256Value = 2024;
}

地址类型 address

地址类型是 Solidity 提供的一种特殊数据类型,主要用于保存以太坊地址,并且拥有一系列的成员变量

  • address: 用于保存 20 字节的太坊地址,;
  • address payable: 保存太坊地址的同时,还拥有额外的 transfer()send() 方法;
1
2
3
4
5
6
7
// SPDX-License-Identifier: MIT
pragma solidity >=0.8.24 <0.9.0;

contract Test {
address myAddress = 0xd82b7E2f20C3FBAc76e74D1C8d8C6af8032bbEc0;
address payable myPayableAddress = payable(myAddress);
}

注意:上述两种地址类型进行转换时,address payable 可以自动转换为 address,而 address 则需要通过 payable(<address>),才能被强制转换为 address payable

枚举类型 enum

枚举类型 enum 用于为从 0 开始计数的 uint 类型数据分配名称(最大不能超过 256),从而提高代码的可读性:

1
2
3
4
5
6
7
// SPDX-License-Identifier: MIT
pragma solidity >=0.8.24 <0.9.0;

contract Test {
enum Cars {HAVAL, GEELY, CHERY} // 把 uint 类型的 0、1、2 表示为 HAVAL、GEELY、CHERY
Cars public myFavoriteCar = Cars.GEELY; // 2
}

引用类型 Reference Type

对引用类型变量进行赋值的时候,实际上传递的是地址指针。

数组 [ ]

Solidity 的数组可以在声明时指定长度(数组元素类型[长度]),也可以动态调整长度(数组元素类型[]):

1
2
3
4
5
6
7
// SPDX-License-Identifier: MIT
pragma solidity >=0.8.24 <0.9.0;

contract Test {
int[5] intArray = [int(1), 2, 3, 4, 5];
uint[] uintArray = [1, 2, 3, 4, 5];
}
1
2
3
4
uint[] memory number = new uint[](3);
number[0] = 1985;
number[1] = 2010;
number[2] = 2024;

注意:Solidity 判别数组元素类型的时候,总是会以第 1 个元素的数据类型作为判定依据。

定长字节数组 bytesX

定长字节数组 bytes1bytes2bytes3 ... bytes32 用于表达从 132 长度的字节序列。

1
2
3
4
5
6
7
8
// SPDX-License-Identifier: MIT
pragma solidity >=0.8.24 <0.9.0;

contract Test {
bytes1 one = "1";
bytes3 three = "123";
bytes5 five = "uinio";
}

变长字节数组 bytes/string

变长字节数组 bytes变长 UTF-8 编码字符串 string 本质上是一种特殊的数组。

  • bytes 类似于 bytes1[],但是由于采用了紧打包,存储空间占用相对较少,更加节省 Gas 费用;
  • stringbytes 相同,不过不允许通过长度或者索引来进行访问;
1
2
3
4
5
6
7
8
9
10
11
12
13
14
// SPDX-License-Identifier: MIT
pragma solidity >=0.8.24 <0.9.0;

contract Test {
/* bytes1[] 类型 */
bytes1 one = "1";
bytes1[] bytes1Array = [one, "2", "3"];

/* bytes 类型 */
bytes bytesArray = "2024";

/* string 类型 */
string test = "Hello Hank!";
}

注意bytesstring 类型都提供有一个 concat() 函数,用于连接两个字符串,该函数会分别返回 bytesstring 类型的 memory 存储位置数组。

结构体 struct

Solidity 可以通过结构体 struct 来自定义数据类型,其中的元素既可以是数值类型,也可以是引用类型

1
2
3
4
5
6
7
8
9
10
11
12
13
// SPDX-License-Identifier: MIT
pragma solidity >=0.8.24 <0.9.0;

contract Test {
/* 声明 PCB 结构体 */
struct PCB{
uint256 width; // 长度
uint256 height; // 高度
uint256 layer; // 层数
}

PCB board = PCB(100, 100, 4); // 初始化 PCB 结构体
}

映射类型 mapping

Solidity 的映射类型使用语法 mapping(键类型 键名称 => 值类型 值名称),其中键和值的名称都可以被省略,映射的值只能在函数内进行修改:

1
2
3
4
5
6
7
8
// SPDX-License-Identifier: MIT
pragma solidity >=0.8.24 <0.9.0;

contract MappingExampleWithNames {
mapping(address => uint) public balances1;

mapping(address user => uint balance) public balances2;
}

Solidity 当中映射的存储位置必须为 storage,向映射新增键值对的语法为 映射名称[键] = 值

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// SPDX-License-Identifier: MIT
pragma solidity >=0.8.24 <0.9.0;

contract MappingExampleWithNames {
mapping(address => uint) public balances1;
function updateBalances1(uint value) public {
balances1[msg.sender] = value;
}

mapping(address user => uint balance) public balances2;
function updateBalances2(uint value) public {
balances2[msg.sender] = value;
}
}

存储位置

可以将引用类型的 数组结构体映射 存储位置,分别指定为 storagememorycalldata,不同存储类型所耗费的 Gas 成本不同:

  • storage:智能合当中的状态变量都默认为 storage 类型,存储在链上面,消耗 Gas 较多。
  • memory:函数当中的参数和临时变量都属于 memory 类型,主要存储在内存当中,不会上链,消耗 Gas 较少。
  • calldata:类似于 memory 存储在内存且不会上链,区别在于存储位置的变量不能被修改,消耗 Gas 较少。

数据类型默认值

不同于 JavaScript,在 Solidity 当中不存在 未定义 或者 值的概念,而且新声明的变量总是被指定为其所属数据类型的默认值

名称 值类型 默认值
布尔类型 boolean false
字符串类型 string ""
整型 int 0
无符号整型 uint 0
枚举类型 enum 首个元素
地址类型 address 0x0000000000000000000000000000000000000000
函数类型 function 空白函数
名称 引用类型 默认值
映射 mapping 所有元素都是其所属数据类型的默认值;
结构体 struct 所有成员都是其所属数据类型的默认值;
数组 array 动态数组默认为 []定长数组为元素所属数据类型的默认值;

Solidity 提供了一个 delete 操作符,可以将指定的变换恢复为初始值:

1
2
3
4
5
6
7
8
9
10
// SPDX-License-Identifier: MIT
pragma solidity >=0.8.24 <0.9.0;

contract Test {
bool public boolean = true;

function update() external {
delete boolean; // 将 boolean 变换为默认值 false
}
}

函数类型 function

函数的定义

Solidity 当中的函数可以接收参数,并且返回相应的处理结果,其基本的定义形式为:

1
2
3
4
5
function 函数名称(参数类型 _参数名称1, 参数类型 _参数名称2)  internal|external|public|private  pure|view|payable  returns(返回值类型 返回值名称1, 返回值类型 返回值名称2){

返回值名称1 = _参数名称1 + 1;
返回值名称2 = _参数名称2 + 2;
}
1
2
3
4
5
6
7
8
9
10
// SPDX-License-Identifier: MIT
pragma solidity >=0.8.24 <0.9.0;

contract Test {
uint public result = add(1); // result = 2

function add(uint parameter) public pure returns (uint a) {
a = parameter + 1;
}
}

当然,也可以显式的在 Solidity 当中使用 return 关键字返回值:

1
2
3
4
function 函数名称(参数类型 _参数名称1, 参数类型 _参数名称2)  internal|external|public|private  pure|view|payable  returns(返回值类型 返回值名称1, 返回值类型 返回值名称2){

return(返回值1, 返回值2);
}
1
2
3
4
5
6
7
8
9
10
// SPDX-License-Identifier: MIT
pragma solidity >=0.8.24 <0.9.0;

contract Test {
uint public result = add(1); // result = 2

function add(uint parameter) public pure returns (uint a) {
return parameter + 1;
}
}

读取返回值

除此之外,Solidity 函数返回值的读取,可以采用解构的方式,一次性读取全部或者部分的返回值:

1
2
3
4
5
6
变量类型 _变量名称1;
变量类型 _变量名称2;

(_变量名称1, _变量名称2) = 函数名称() // 读取所有返回值
(_变量名称1, ) = 函数名称() // 只读取部分返回值
(, _变量名称2) = 函数名称() // 只读取部分返回值
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// SPDX-License-Identifier: MIT
pragma solidity >=0.8.24 <0.9.0;

contract Test {
function add(uint parameter) public pure returns (uint a, uint b, uint c) {
a = parameter + 1;
b = parameter + 2;
c = parameter + 3;
}

function invoke() public pure {
uint resultA;
uint resultB;
uint resultC;
(resultA, resultB, resultC) = add(1);
}
}

状态可变性 view pure

声明为 view 的函数,可以读取状态,但是不能修改状态,这里的状态是指:

  1. 修改状态变量;
  2. 产生事件;
  3. 创建其它智能合约;
  4. 使用了 selfdestruct
  5. 通过调用发送 ETH 以太币;
  6. 调用没有被标记为 view 或者 pure 的函数;
  7. 使用了低级调用;
  8. 使用了包含特定操作码的内联汇编;
1
2
3
4
5
6
7
8
// SPDX-License-Identifier: MIT
pragma solidity >=0.8.24 <0.9.0;

contract C {
function sum(uint256 a, uint256 b) public view returns (uint256) {
return a + b + block.timestamp;
}
}

声明为 pure 的函数,即不能读取状态,也不能修改状态,而这里的状态则是指:

  1. 读取状态变量。
  2. 访问 address(this).balance 或者 <address>.balance
  3. 访问 blocktxmsg 当中的成员(除 msg.sigmsg.data 之外)。
  4. 调用没有被标记为 pure 的函数。
  5. 使用了包含某些操作码的内联汇编。
1
2
3
4
5
6
7
8
// SPDX-License-Identifier: MIT
pragma solidity >=0.8.24 <0.9.0;

contract C {
function sum(uint256 a, uint256 b) public pure returns (uint256) {
return a + b;
}
}

注意:由于被声明为 pureview 的函数不能修改状态变量,因而调用时也就无需被收取 Gas 费用。

构造函数 constructor

每一份 Solidity 智能合约都可以定义一个 constructor 构造函数,该函数会在智能合约部署的时候自动被执行一次,因而可以用于初始化一些参数:

1
2
3
4
5
6
7
8
9
10
11
// SPDX-License-Identifier: MIT
pragma solidity >=0.8.24 <0.9.0;

contract Test {
address owner; // 定义一个 owner 变量

/* 构造函数 */
constructor() {
owner = msg.sender; // 该智能合约部署的时候,会将 owner 设置为部署者的地址
}
}

函数修饰器 modifier

Solidity 提供的 modifier 修饰器语法,能够以声明的方式来改变一些函数的行为,例如在执行函数之前自动进行一个检查:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// SPDX-License-Identifier: MIT
pragma solidity >=0.8.24 <0.9.0;

contract Test {
address owner; // 定义一个 owner 变量

/* 构造函数 */
constructor() {
owner = msg.sender; // 该智能合约部署的时候,会将 owner 设置为部署者的地址
}

/* 定义一个 modifier 修饰器 */
modifier onlyOwner() {
require(msg.sender == owner); // 检查调用者地址
_; // 如果是就继续运行,否则报错并且回滚交易
}

function updateOwner(address _newOwner) external onlyOwner {
owner = _newOwner; // 只有 owner 地址运行这个函数,并改变owner
}
}

注意:函数修饰器提供的占位符语句 _ 用于表示添加了修饰符的函数主体被插入的位置。

函数与状态变量的可见性

状态变量可见性

  1. 声明为 public 的状态变量:编译器会自动为其生成 Getter 函数,从而允许其它智能合约读取其值。除此之外,同一个合约当中使用时,通过 this.x 外部访问时也会调用 Getter 函数,而通过 x 直接内部访问则会直接从存储获取变量值。 由于没有生成 Setter 函数,所以其它智能合约无法修改其值。
  2. 声明为 internal 的状态变量:只能从其所定义的智能合约,或者派生出的智能合约当中进行访问,这也是状态变量的默认的可见性
  3. 声明为 private 的状态变量:类似于内部变量,但是在派生出的智能合约当中不可以访问。

函数的可见性

  1. 声明为 external 的函数:只能被其它智能合约或者交易调用,不能从智能合约内部被调用(无法通过 ext() 调用,但是可以通过 this.ext() 调用)。
  2. 声明为 public 的函数:可以被任何智能合约或者交易调用。
  3. 声明为 internal 的函数:只能在当前智能合约内部或者派生的智能合约当中访问,不能从智能合约的外部进行访问。
  4. 声明为 private 的函数:只能在被定义的智能合约内部进行访问,无论是外部还是派生的智能合约都无法进行访问。

事件 event

Solidity 当中事件 event 的本质是以太坊虚拟机 EVM 日志功能的抽象,

1
event 事件名称(事件变量类型 事件变量名称);

下面的示例代码,每次调用 transfer() 函数进行转账的时候,都会触发 Transfer 事件,并且记录对应的变量:

1
2
3
4
5
6
7
8
9
10
11
12
// SPDX-License-Identifier: MIT
pragma solidity >=0.8.24 <0.9.0;

contract Test {
event Transfer(address indexed from, address indexed to, uint256 value);

/* 定义用于执行转帐的 transfer 函数 */
function transfer(address from, address to, uint256 value) external {
// ... ... ... ...
emit Transfer(from, to, value); // 触发事件
}
}

注意:上面代码当中出现的 indexed 关键字,可以将变量保存在以太坊虚拟机 EVM 日志的 topics 当中,从而可以方便的在后续进行检索。

以太坊虚拟机 EVM 会使用日志 Log 来存储 Solidity 事件,每一条 Log 日志都记录着 topics 主题和 data 数据两个部分:

  1. 主题 Topics:用于描述事件,只能容纳 32 个字节,且只能保存最多三个 indexed 参数;
  2. 数据 Data:用于保存没有被标注为 indexed 的参数,可以存储任意大小的数据;

异常处理 error

Solidity 可以使用 error() 方法定义一个不带参数的异常:

1
2
3
4
5
6
// SPDX-License-Identifier: MIT
pragma solidity >=0.8.24 <0.9.0;

contract Test {
error TransferError(); // 定义一个没有参数的异常
}

除此之外,也可以使用 error() 方法定义一个携带有参数的异常:

1
2
3
4
5
6
// SPDX-License-Identifier: MIT
pragma solidity >=0.8.24 <0.9.0;

contract Test {
error TransferError(address sender); // 定义一个带有地址参数的异常
}

通常情况下,error() 必须搭配回退命令 revert 进行使用。

1
2
3
4
5
6
7
8
9
10
// SPDX-License-Identifier: MIT
pragma solidity >=0.8.24 <0.9.0;

contract Test {
error TransferError(address sender);

function transferOwner() public view {
revert TransferError(msg.sender); // 使用 revert 回退命令抛出异常
}
}

除此之外,一些较早版本的 Solidity 还会使用已经废弃了的 require() 方法来处理异常,其缺点在于 Gas 费用会伴随异常描述字符串长度的增加而增加。

1
require(异常检查条件,"异常描述信息");

而另外一个 assert() 方法则不能抛出自定义的异常信息,只能直接抛出默认的异常错误:

1
assert(异常检查条件);

继承机制 is

Solidity 当中的智能合约可以通过 is 关键字来继承其它合约,从而扩展其功能。子合约可以继承父合约当中 internalpublic 的函数、状态变量以及事件。

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
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

/* 父合约 */
contract ParentContract {
uint public parentVariable; // 父合约状态变量

/* 父合约函数 */
function parentFunction() public virtual {} // virtual 关键字用于明确标识一个函数可以在子合约当中被重写

/* 父合约事件 */
event ParentEvent(uint indexed value);
}

/* 子合约 */
contract ChildContract is ParentContract {
uint public childVariable; // 子合约状态变量

/* 重写父合约函数 */
function parentFunction() public override {}

/* 子合约函数 */
function childFunction() public {}

/* 触发父合约事件 */
function triggerParentEvent(uint value) public {
emit ParentEvent(value);
}
}

注意:Solidity 只能实现单继承,即一个子合约只能直接继承自一个父合约。

模块化导入 import

Solidity 支持 import 模块化导入,下面的一语句用于全局导入,可以将 filename 导入路径源文件中的全局符号引入到当前源文件,但是会污染当前 Solidity 源文件的命名空间,并不建议使用:

1
import "filename";

下面的导入语句将 filename 当中的全局符号,导入到了一个新的命名空间 symbolName 当中,从而有效避免了命名空间的污染:

1
import * as symbolName from "filename";

上述的语句,可以简化的写为如下的形式:

1
import "filename" as symbolName;

如果导入源文件当中的命名符号存在冲突,则可以在导入的时候对其进行重命名:

1
import { symbol1 as alias, symbol2 } from "filename";

OpenZeppelin 基础

OpenZeppelin 是一家成立于 2015 年的区块链技术企业,其推出的 contracts 是一款是用于开发安全智能合约的开源 Solidity 库,其主要提供了以下三方面的功能:

  1. 访问控制:用于在智能合约当中,指定每个角色可以进行的操作。
  2. Tokens:创建可以交易的资产或数字藏品,例如 ERC20 或者 ERC721。
  3. 工具:一些通用工具函数,包括不会溢出的数学运算、签名验证等。

可以通过下面的 npm 命令快速安装 OpenZeppelincontracts 库:

1
npm install @openzeppelin/contracts

OpenZeppelin 提供的大多数特性,都需要通过 Solidity 的 is 关键字,以继承的方式来进行使用:

1
2
3
4
5
6
7
8
9
10
// contracts/MyNFT.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

import {ERC721} from "@openzeppelin/contracts/token/ERC721/ERC721.sol";

contract MyNFT is ERC721 {
constructor() ERC721("MyNFT", "MNFT") {
}
}

除此之外,还可以通过 Solidity 的 overrides 来重 OpenZeppelin 当中提供的功能,例如希望改变 AccessControl 中的 revokeRole() 方法:

1
2
3
4
5
6
7
8
9
10
pragma solidity ^0.8.20;

import {AccessControl} from "@openzeppelin/contracts/access/AccessControl.sol";

contract ModifiedAccessControl is AccessControl {
// 重写 revokeRole() 函数
function revokeRole(bytes32, address) public override {
revert("ModifiedAccessControl: cannot revoke roles");
}
}

有时候会想继承某一部分 OpenZeppelin 当中的功能,并非完全的重写它们,此时就需要用使用到 solidity 的 super 关键字:

1
2
3
4
5
6
7
8
9
10
11
12
pragma solidity ^0.8.20;
import "@openzeppelin/contracts/access/AccessControl.sol";
contract ModifiedAccessControl is AccessControl {
function revokeRole(bytes32 role, address account) public override {
require(
role != DEFAULT_ADMIN_ROLE,
"ModifiedAccessControl: cannot revoke default admin role"
);
// super.revokeRole 语句将会调用 AccessControll 的原始 revokeRole 方法
super.revokeRole(role, account);
}
}

访问控制 Ownerable.sol

OpenZeppelin 将发布智能合约的账户称为 owner,其提供了 Ownerable.sol 来管理智能合约当中的所有权。

1
2
3
4
5
6
7
pragma solidity ^0.8.20;
import "@openzeppelin/contracts/access/Ownable.sol";

contract MyContract is Ownable {
function normalThing() public {} // 所有人都可以调用
function specialThing() public onlyOwner {} // 只有 Owner 才可以调用
}

注意:示例代码当中的 onlyOwner 关键字是由 Openzeppelin 当中的 Ownerable.sol 所提供的。

Ownerable.sol 主要提供了如下两个功能函数:

  • transferOwnership() 将智能合约的所有权转移给另外一个账户;
  • renounceOwnership() 放弃智能合约的所有权关系;

访问控制 AccessControl.sol

除此之外,OpenZeppelin 还提供了 AccessControl.sol 来基于角色进行访问控制(即定义多个角色,并且每个角色对应一组操作权限)。其使用非常简单,对于每个定义的角色都会创建一个角色标识符,用于授权、撤销、检查账户是否拥有该角色。

下面是一个基于 ERC20 Token 使用 AccessControl.sol 的例子,它定义了一个名为 minter 的角色,该角色允许账户创建新的 token

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
pragma solidity ^0.8.20;
import "@openzeppelin/contracts/access/AccessControl.sol";
import "@openzeppelin/contracts/token/ERC20/ERC20.sol";

contract MyToken is ERC20, AccessControl {
// 为 minter 角色创建一个新的角色标识符
bytes32 public constant MINTER_ROLE = keccak256("MINTER_ROLE");

constructor(address minter) ERC20("MyToken", "TKN") {
_setupRole(MINTER_ROLE, minter); // 将 minter 角色授予指定帐户
}

function mint(address to, uint256 amount) public {
require(hasRole(MINTER_ROLE, msg.sender), "Caller is not a minter"); // 检查调用帐号是否具有 minter 角色
_mint(to, amount);
}
}

OpenZeppelin 提供的 AccessControl.sol 亮点在于需要细粒度权限控制的场景,这可以通过定义多个角色来实现。通过这样的拆分,可以实现比 Ownerable.sol 提供的简单所有权控制,层级要更多的访问控制。请注意,如果需要的话,同一个账户可以拥有多个不同的角色

注意:限制系统中每个组件能做的事情被称为最小权限原则

接下来定义一个 burner 角色来扩展上面的 ERC20 token 示例,并且通过使用 onlyRole 修饰符来允许账户销毁 token

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
pragma solidity ^0.8.20;
import "@openzeppelin/contracts/access/AccessControl.sol";
import "@openzeppelin/contracts/token/ERC20/ERC20.sol";

contract MyToken is ERC20, AccessControl {
bytes32 public constant MINTER_ROLE = keccak256("MINTER_ROLE");
bytes32 public constant BURNER_ROLE = keccak256("BURNER_ROLE");

constructor(address minter, address burner) ERC20("MyToken", "TKN") {
_setupRole(MINTER_ROLE, minter); // 设置 minter 访问权限
_setupRole(BURNER_ROLE, burner); // 设置 burner 访问权限
}

// 通过使用 `onlyRole` 修饰符来允许账户销毁 token
function mint(address to, uint256 amount) public onlyRole(MINTER_ROLE) {
_mint(to, amount);
}
function burn(address from, uint256 amount) public onlyRole(BURNER_ROLE) {
_burn(from, amount);
}
}

上面的代码当中,使用了内部函数 _setupRole() 来分配角色,除此之外还可以使用以下的工具函数来管理角色:

  • hasRole():判断角色。
  • grantRole():授予角色。
  • revokeRole():回收角色。

除此之外,OpenZeppelin 提供的 AccessControl.sol 当中,还包含有一个称为 DEFAULT_ADMIN_ROLE 的特殊角色,它是所有角色的默认管理员,拥有该角色的账户可以去管理其它的角色,除非手工调用 _setRoleAdmin() 内部函数来指定一个新的管理员。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
pragma solidity ^0.8.0;
import "@openzeppelin/contracts/access/AccessControl.sol";
import "@openzeppelin/contracts/token/ERC20/ERC20.sol";

contract MyToken is ERC20, AccessControl {
bytes32 public constant MINTER_ROLE = keccak256("MINTER_ROLE");
bytes32 public constant BURNER_ROLE = keccak256("BURNER_ROLE");

constructor() ERC20("MyToken", "TKN") {
// 授予合约部署者默认的 DEFAULT_ADMIN_ROLE 角色,使其能够授予和撤销任何角色
_setupRole(DEFAULT_ADMIN_ROLE, msg.sender);
}

function mint(address to, uint256 amount) public onlyRole(MINTER_ROLE) {
_mint(to, amount);
}

function burn(address from, uint256 amount) public onlyRole(BURNER_ROLE) {
_burn(from, amount);
}
}

除此之外,AccessControl.sol 还提供了如下两个工具函数:

  • getRoleMember():返回某个角色当中账户的地址;
  • getRoleMemberCount():返回某个角色当中账户的数量;
1
2
3
4
5
6
7
8
// 返回某个角色当中账户的数量
const minterCount = await myToken.getRoleMemberCount(MINTER_ROLE);

// 返回某个角色当中账户的地址
const members = [];
for (let i = 0; i < minterCount; ++i) {
members.push(await myToken.getRoleMember(MINTER_ROLE, i));
}

代币 ERC20 Token

代币(Token)是指区块链上,各种可以通过智能合约来调用、交易、创建、销毁的虚拟资产,其中 ERC721 是以太坊上用于非同质化代币(NFT,Non Fungible Token)的标准,OpenZeppelin 针对 ERC721 标准提供了大量的接口方法,下面的代码可以用于构建一个 ERC721 代币智能合约:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
pragma solidity ^0.8.20;

import "@openzeppelin/contracts/token/ERC721/extensions/ERC721URIStorage.sol";
import "@openzeppelin/contracts/utils/Counters.sol";

contract GameItem is ERC721URIStorage {
using Counters for Counters.Counter;
Counters.Counter private _tokenIds;

constructor() ERC721("GameItem", "ITM") {}

function awardItem(address player, string memory tokenURI) public returns (uint256) {
uint256 newItemId = _tokenIds.current();
_mint(player, newItemId);
_setTokenURI(newItemId, tokenURI); // 设置物品的元数据

_tokenIds.increment();
return newItemId;
}
}

在上面的示例代码当中,新的 NFT 可以通过执行如下代码来进行生成:

1
2
3
> gameItem.awardItem(playerAddress, "http://uinio.com/NFT.json")

- Transfer(0x0000000000000000000000000000000000000000, playerAddress, 5)

并且每个物品的所有者和元数据都可以通过如下的方式进行查询:

1
2
3
4
5
> gameItem.ownerOf(5)
playerAddress

> gameItem.tokenURI(5)
"http://uinio.com/NFT.json"

最终,获得的 tokenURI 就是一个如下所示的 JSON 格式数据:

1
2
3
4
5
6
{
"name": "雷神之锤",
"description": "一个漫威电影当中的道具",
"image": "http://localhost:1985/Web/Solidity/logo.png",
"strength": 20
}

财务功能 Finance

OpenZeppelin 在 Finance 目录下提供了 PaymentSplitter(只存在于 Contracts 库的 4.x 版本)和 VestingWallet(存在于 Contracts 库的 4.x5.x 版本)两个财务相关的智能合约:

  • PaymentSplitter 智能合约:通常用于管理和分配资金,可以允许将资金分割并发送到多个地址,通常基于预定义的分配规则或比例。通常用于众筹、团队资金分配或任何需要按照特定比例分割资金的场景。
  • VestingWallet 智能合约:则是一种特殊的钱包,用于管理资产的逐步解锁或归属。它通常用于确保代币或资金在一定时间段内逐步释放给接收者,而不是立即全部可用。通常用于激励计划、团队代币锁定或者任何需要时间限制的资金释放场景。

概括起来,PaymentSplitterVestingWallet 两者的区别主要体现在如下四个方面:

  1. 目的不同PaymentSplitter 旨在分割和分配资金,而 VestingWallet 旨在逐步解锁和释放资金。
  2. 使用场景不同PaymentSplitter 更适用于一次性的资金分配场景,而 VestingWallet 更适用于需要长期管理和逐步释放资金的场景。
  3. 功能不同PaymentSplitter 主要关注资金的即时分配,而 VestingWallet 关注资金的时间锁定和逐步解锁。
  4. 透明度与可追踪性:两者都可能提供事件来增强透明度和可追踪性,但事件的具体内容和触发条件会根据合约的具体实现而有所不同。

PaymentSplitter 分帐

OpenZeppelin 的 PaymentSplitter 智能合约库允许将一个以太坊地址收到的付款按照指定的份额(Shares)进行分割,并将这些部分按指定的份额值发送给收款人。这个合约非常适合用于在多个团队成员、投资者或合作伙伴之间分配资金的情况。

PaymentSplitter 提供的方法 功能描述
constructor(payees, shares_) 构造函数,数组 payees(收款人)当中的每个账户,都会获得 shares_(份额)数组中匹配位置的份额值。
receive() 接收 ETH 以太币(会被记录至 PaymentReceived 事件)。
totalShares() 获取收款人 payees 持有的全部份额。
totalReleased() 获取已经释放的 ETH 以太币总额。
totalReleased(token) 获取已经释放的 token 代币总额。
shares(account) 获取指定地址账户持有的份额值。
released(account) 获取已释放给指定地址收款人的 ETH 以太币数量。
released(token, account) 获取已释放给指定地址收款人的 token 代币数量。
payee(index) 获取收款人数组 payees 指定 index 索引的收款人的账户地址。
releasable(account) 获取指定账户地址的收款人,当前可以释放的 ETH 以太币数量。
releasable(token, account) 获取指定账户地址的收款人,当前可以释放的 token 代币数量。
release(account) 根据持有的份额比例和之前的提款历史,向指定账户地址的收款人释放 ETH 以太币。
release(token, account) 根据持有的份额比例和之前的提款历史,向指定账户地址的收款人释放 token 代币。
PaymentSplitter 提供的事件 功能描述
PayeeAdded(account, shares) 收款人添加事件,需要指定其账户地址 account 以及所占份额 shares
PaymentReceived(from, amount) 智能合约收款事件,向 from 地址收取 amount 数额 ETH 以太币的事件。
PaymentReleased(to, amount) 受益人提款事件,即向 to 地址支付 amount 数额 ETH 以太币的事件。
ERC20PaymentReleased(token, to, amount) 受益人提款事件,即向 to 地址支付 amount 数额 token 代币的事件。

注意:上述表格当中的 token 是一个 IERC20 代币智能合约的地址。

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
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

import "../token/ERC20/utils/SafeERC20.sol";
import "../utils/Address.sol";
import "../utils/Context.sol";

contract PaymentSplitter is Context {
event PayeeAdded(address account, uint256 shares);
event PaymentReleased(address to, uint256 amount);
event ERC20PaymentReleased(IERC20 indexed token, address to, uint256 amount);
event PaymentReceived(address from, uint256 amount);

uint256 private _totalShares; // 总份额
uint256 private _totalReleased; // 总提款

mapping(address => uint256) private _shares; // 每个受益人占有的份额
mapping(address => uint256) private _released; // 支付给每个受益人的金额
address[] private _payees; // 受益人数组

mapping(IERC20 => uint256) private _erc20TotalReleased;
mapping(IERC20 => mapping(address => uint256)) private _erc20Released;

/** @dev 构造函数,数组 payees(收款人)当中的每个账户,都会获得 shares_(份额)数组中匹配位置的份额值 */
constructor(address[] memory payees, uint256[] memory shares_) payable {
require(payees.length == shares_.length, "PaymentSplitter: payees and shares length mismatch");
require(payees.length > 0, "PaymentSplitter: no payees");

for (uint256 i = 0; i < payees.length; i++) {
_addPayee(payees[i], shares_[i]);
}
}

/** @dev 接收 ETH 以太币(会被记录至 PaymentReceived 事件) */
receive() external payable virtual {
emit PaymentReceived(_msgSender(), msg.value);
}

/** @dev 获取收款人 payees 持有的全部份额 */
function totalShares() public view returns (uint256) {
return _totalShares;
}

/** @dev 获取已经释放的 ETH 以太币总额 */
function totalReleased() public view returns (uint256) {
return _totalReleased;
}

/** @dev 获取已经释放的 token 代币总额 */
function totalReleased(IERC20 token) public view returns (uint256) {
return _erc20TotalReleased[token];
}

/** @dev 获取指定地址账户持有的份额值 */
function shares(address account) public view returns (uint256) {
return _shares[account];
}

/** @dev 获取已释放给指定地址收款人的 ETH 以太币数量 */
function released(address account) public view returns (uint256) {
return _released[account];
}

/** @dev 获取已释放给指定地址收款人的 token 代币数量 */
function released(IERC20 token, address account) public view returns (uint256) {
return _erc20Released[token][account];
}

/** @dev 获取收款人数组 payees 指定 index 索引的收款人的账户地址 */
function payee(uint256 index) public view returns (address) {
return _payees[index];
}

/** @dev 获取指定账户地址的收款人,当前可以释放的 ETH 以太币数量 */
function releasable(address account) public view returns (uint256) {
uint256 totalReceived = address(this).balance + totalReleased();
return _pendingPayment(account, totalReceived, released(account));
}

/** @dev 获取指定账户地址的收款人,当前可以释放的 token 代币数量 */
function releasable(IERC20 token, address account) public view returns (uint256) {
uint256 totalReceived = token.balanceOf(address(this)) + totalReleased(token);
return _pendingPayment(account, totalReceived, released(token, account));
}

/** @dev 根据持有的份额比例和之前的提款历史,向指定账户地址的收款人释放 ETH 以太币 */
function release(address payable account) public virtual {
require(_shares[account] > 0, "PaymentSplitter: account has no shares"); // account 必须是有效的受益人
uint256 payment = releasable(account); // 计算 account 可以得到的金额
require(payment != 0, "PaymentSplitter: account is not due payment"); // 可以得到的金额不能低于零

/* 更新支付总额 totalReleased 和支付给每一个受益人的金额 released */
_totalReleased += payment;
unchecked {
_released[account] += payment;
}
/* 开始转帐 */
Address.sendValue(account, payment);
emit PaymentReleased(account, payment);
}

/** @dev 根据持有的份额比例和之前的提款历史,向指定账户地址的收款人释放 token 代币 */
function release(IERC20 token, address account) public virtual {
require(_shares[account] > 0, "PaymentSplitter: account has no shares");
uint256 payment = releasable(token, account);
require(payment != 0, "PaymentSplitter: account is not due payment");

_erc20TotalReleased[token] += payment;
unchecked {
_erc20Released[token][account] += payment;
}

SafeERC20.safeTransfer(token, account, payment);
emit ERC20PaymentReleased(token, account, payment);
}

/**
* @dev 内部逻辑,计算一个账户的待支付金额,需要考虑该账户的历史余额和已提取的金额 */
function _pendingPayment(
address account,
uint256 totalReceived,
uint256 alreadyReleased
) private view returns (uint256) {
/* 收款总额 x 账户份额 / 总份额 - 已提取金额 */
return (totalReceived * _shares[account]) / _totalShares - alreadyReleased;
}

/** @dev 新增受益人 account 以及其所对应的份额 shares_ */
function _addPayee(address account, uint256 shares_) private {
require(account != address(0), "PaymentSplitter: account is the zero address"); // account 不能为零地址
require(shares_ > 0, "PaymentSplitter: shares are 0"); // 份额不能为 0
require(_shares[account] == 0, "PaymentSplitter: account already has shares"); // 该账户是否已经拥有份额

/* 更新收款人数组 _payees、份额数组 _shares、总份额 _totalShares */
_payees.push(account);
_shares[account] = shares_;
_totalShares = _totalShares + shares_;
emit PayeeAdded(account, shares_); // 触发增加受益人事件
}
}

VestingWallet 归属权钱包

OpenZeppelinVestingWallet 智能合约库,主要用于实现代币的逐步发放功能。这里的 Vesting 是一种归属权兑现机制,用于在一定时间内将代币 token 发放给特定的受益人。换而言之,就是在一定时间期限内,代币逐渐可用或者可提取的过程。

VestingWallet 提供的方法 功能描述
constructor(beneficiary, startTimestamp, durationSeconds) 默认将智能合约的发送方设置为初始所有者,其中 beneficiary 为受益人,startTimestamp 为开始时间戳,durationSeconds 为归属权存续时间。
receive() 用于接收 ETH 以太币。
start() 获取开始时间戳。
end() 获取结束时间戳。
duration() 获取归属权存续时间。
released() 已被释放的 ETH 以太币数量。
released(address token) 已被释放的 token 代币数量。
releasable() 可以释放的 ETH 以太币数量。
releasable(address token) 可以释放的 token 代币数量。
release() 释放已归属的 ETH 以太币。
release(token) 释放已归属的 token 代币。
vestedAmount(timestamp) 已归属的 ETH 以太币数量,默认实现为一个线性的释放曲线。
vestedAmount(token, timestamp) 已归属的 token 代币数量,默认实现为一个线性的释放曲线。
_vestingSchedule(totalAllocation, timestamp) 归属权公式的虚拟实现,返回值为已经释放的金额。
VestingWallet 提供的事件 功能描述
EtherReleased(amount) 以太币 ETH 被释放事件。
ERC20Released(token, amount) 代币 token 被释放事件。

注意:上述表格当中的 token 是一个 IERC20 代币智能合约的地址。

OpenZeppelinContracts 库在其 finance 目录下的 VestingWallet.sol 智能合约当中提供了如下源代码:

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
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

import {IERC20} from "../token/ERC20/IERC20.sol";
import {SafeERC20} from "../token/ERC20/utils/SafeERC20.sol";
import {Address} from "../utils/Address.sol";
import {Context} from "../utils/Context.sol";
import {Ownable} from "../access/Ownable.sol";

contract VestingWallet is Context, Ownable {
event EtherReleased(uint256 amount);
event ERC20Released(address indexed token, uint256 amount);

uint256 private _released;
mapping(address token => uint256) private _erc20Released;
uint64 private immutable _start;
uint64 private immutable _duration;

/** @dev 默认将智能合约的发送方设置为初始所有者,其中 beneficiary 为受益人,startTimestamp 为开始时间戳,durationSeconds 为归属权存续时间 */
constructor(address beneficiary, uint64 startTimestamp, uint64 durationSeconds) payable Ownable(beneficiary) {
_start = startTimestamp;
_duration = durationSeconds;
}

/** @dev 用于接收 ETH 以太币 */
receive() external payable virtual {}

/** @dev 获取开始时间戳 */
function start() public view virtual returns (uint256) {
return _start;
}

/** @dev 获取归属权存续时间 */
function duration() public view virtual returns (uint256) {
return _duration;
}

/** @dev 获取结束时间戳 */
function end() public view virtual returns (uint256) {
return start() + duration();
}

/** @dev 已被释放的 ETH 以太币数量。 */
function released() public view virtual returns (uint256) {
return _released;
}

/** @dev 已被释放的 token 代币数量 */
function released(address token) public view virtual returns (uint256) {
return _erc20Released[token];
}

/** @dev 可以释放的 ETH 以太币数量 */
function releasable() public view virtual returns (uint256) {
return vestedAmount(uint64(block.timestamp)) - released();
}

/** @dev 可以释放的 token 代币数量 */
function releasable(address token) public view virtual returns (uint256) {
return vestedAmount(token, uint64(block.timestamp)) - released(token);
}

/** @dev 释放已归属的 ETH 以太币 */
function release() public virtual {
uint256 amount = releasable();
_released += amount;
emit EtherReleased(amount);
Address.sendValue(payable(owner()), amount);
}

/** @dev 释放已归属的 token 代币。 */
function release(address token) public virtual {
uint256 amount = releasable(token); // 获取可以释放的 token 代币数量
_erc20Released[token] += amount; // 更新已释放代币数量
emit ERC20Released(token, amount); // 触发代币 token 被释放事件
SafeERC20.safeTransfer(IERC20(token), owner(), amount); // 安全转移代币
}

/** @dev 已归属的 ETH 以太币数量,默认实现为一个线性的释放曲线 */
function vestedAmount(uint64 timestamp) public view virtual returns (uint256) {
return _vestingSchedule(address(this).balance + released(), timestamp);
}

/** @dev 已归属的 token 代币数量,默认实现为一个线性的释放曲线 */
function vestedAmount(address token, uint64 timestamp) public view virtual returns (uint256) {
return _vestingSchedule(IERC20(token).balanceOf(address(this)) + released(token), timestamp);
}

/** @dev 归属权公式的虚拟实现,返回值为已经释放的金额 */
function _vestingSchedule(uint256 totalAllocation, uint64 timestamp) internal view virtual returns (uint256) {
if (timestamp < start()) {
return 0;
} else if (timestamp >= end()) {
return totalAllocation;
} else {
return (totalAllocation * (timestamp - start())) / duration(); // 根据线性释放公式,计算已经释放的数量
}
}
}

基于 Solidity、Hardhat、OpenZeppelin 迈向 Web3.0

http://www.uinio.com/Web/Solidity/

作者

Hank

发布于

2024-02-22

更新于

2024-03-28

许可协议