Solidity入门学习(二)
2025-01-27 13:08:36 # Web3

控制流

这一讲,我们将介绍Solidity中的控制流。

测试代码Control.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
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.21;
contract Controlflow {
// if else
function ifElseTest(uint256 _number) public pure returns(bool){
if(_number == 0){
return(true);
}else{
return(false);
}
}

// for loop
function forLoopTest() public pure returns(uint256){
uint sum = 0;
for(uint i = 0; i < 10; i++){
sum += i;
}
return(sum);
}

// while
function whileTest() public pure returns(uint256){
uint sum = 0;
uint i = 0;
while(i < 10){
sum += i;
i++;
}
return(sum);
}

// do-while
function doWhileTest() public pure returns(uint256){
uint sum = 0;
uint i = 0;
do{
sum += i;
i++;
}while(i < 10);
return(sum);
}

// 三元运算符 ternary/conditional operator
function ternaryTest(uint256 x, uint256 y) public pure returns(uint256){
// return the max of x and y
return x >= y ? x: y;
}
}

注意⚠️Solidity中最常用的变量类型是uint,也就是正整数,取到负值的话,会报underflow错误。

控制流

Solidity的控制流与其他语言类似,主要包含以下几种:

  1. if-else
1
2
3
4
5
6
7
function ifElseTest(uint256 _number) public pure returns(bool){
if(_number == 0){
return(true);
}else{
return(false);
}
}
  1. for循环
1
2
3
4
5
6
7
function forLoopTest() public pure returns(uint256){
uint sum = 0;
for(uint i = 0; i < 10; i++){
sum += i;
}
return(sum);
}
  1. while循环
1
2
3
4
5
6
7
8
9
function whileTest() public pure returns(uint256){
uint sum = 0;
uint i = 0;
while(i < 10){
sum += i;
i++;
}
return(sum);
}
  1. do-while循环
1
2
3
4
5
6
7
8
9
function doWhileTest() public pure returns(uint256){
uint sum = 0;
uint i = 0;
do{
sum += i;
i++;
}while(i < 10);
return(sum);
}
  1. 三目运算

三元运算符是Solidity中唯一一个接受三个操作数的运算符,规则条件? 条件为真的表达式:条件为假的表达式。此运算符经常用作if语句的快捷方式。

1
2
3
4
5
// 三元运算符 ternary/conditional operator
function ternaryTest(uint256 x, uint256 y) public pure returns(uint256){
// return the max of x and y
return x >= y ? x: y;
}

另外还有continue(立即进入下一个循环)和break(跳出当前循环)关键字可以使用。

构造函数和修饰器

这一讲,我们将用合约权限控制(Ownable)的例子介绍Solidity语言中构造函数(constructor)和独有的修饰器(modifier)。测试代码Owner.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
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.21;

contract Owner {
//定义owner变量
address public owner;

//构造函数
constructor(address initialOwner){
//在部署合约时,将owner设置为initialOwner
owner = initialOwner;
}
modifier onlyOwner{
//检查地址是否为owner
require(msg.sender == owner);
//如果是的话,继续运行函数主体,否则报错并revert交易
_;
}
//
function changeOwner(address _newOwner) external onlyOwner{
//只有owner能运行这个函数
owner = _newOwner;
}
}

构造函数

构造函数(constructor)是一种特殊的函数,每个合约可以定义一个,并在部署合约的时候自动运行一次。它可以用来初始化合约的一些参数,例如初始化合约的owner地址。

1
2
3
4
5
address owner; // 定义owner变量
// 构造函数
constructor(address initialOwner) {
owner = initialOwner; // 在部署合约的时候,将owner设置为传入的initialOwner地址
}

注意:构造函数在不同的Solidity版本中的语法并不一致,在Solidity 0.4.22之前,构造函数不使用 constructor 而是使用与合约名同名的函数作为构造函数而使用,由于这种旧写法容易使开发者在书写时发生疏漏(例如合约名叫 Parents,构造函数名写成 parents),使得构造函数变成普通函数,引发漏洞,所以0.4.22版本及之后,采用了全新的 constructor 写法。

构造函数的旧写法代码示例:

1
2
3
4
5
6
pragma solidity =0.4.21;
contract Parents {
// 与合约名Parents同名的函数就是构造函数
function Parents () public {
}
}

修饰器

修饰器(modifier)是Solidity特有的语法,类似于面向对象编程中的装饰器(decorator),声明函数拥有的特性,并减少代码冗余。它就像钢铁侠的智能盔甲,穿上它的函数会带有某些特定的行为。modifier的主要使用场景是运行函数前的检查,例如地址,变量,余额等。

我们来定义一个叫做onlyOwner的modifier:

1
2
3
4
5
6
modifier onlyOwner{
//检查地址是否为owner
require(msg.sender == owner);
//如果是的话,继续运行函数主体,否则报错并revert交易
_;
}

带有onlyOwner修饰符的函数只能被owner地址调用,比如下面这个例子:

1
2
3
4
5
//定义一个带有onlyOwner修饰符的函数
function changeOwner(address _newOwner) external onlyOwner{
//只有owner能运行这个函数,并改变owner
owner = _newOwner;
}

我们定义了一个changeOwner函数,运行它可以改变合约的owner,但是由于onlyOwner修饰符的存在,只有原先的owner可以调用,别人调用就会报错。这也是最常用的控制智能合约权限的方法。

运行测试

首先是部署代码,传递一个需要初始化的owner,初始化owner0x5B38Da6a701c568545dCfcB03FcB875f56beddC4

image-20250126150027700

使用另一个用户0xAb8483F64d9C6d1EcF9b849Ae677dD3315835cb2,去调用合约,并尝试修改ownerowner修改失败!

image-20250126150311002

image-20250126150255810

尝试使用部署时输入的账户``0x5B38Da6a701c568545dCfcB03FcB875f56beddC4`。

去改变owner0xAb8483F64d9C6d1EcF9b849Ae677dD3315835cb2owner修改成功!

image-20250126150540552

image-20250126150740601

事件

这一讲,我们用转账ERC20代币为例来介绍Solidity中的事件(event)。

测试代码Events.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
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.4;
contract Events{
//定义_balances映射变量,记录每个地址的比特币数量
mapping(address => uint256) public _balances;

//定义Transfer event,记录transfer交易的转账地址,接收地址和转账数量。
event Transfer(address indexed from,address indexed to,uint256 value);

//定义_transfer函数,执行转账逻辑
function _transfer(
address from,
address to,
uint256 amount
) external {
//给转账地址一些初始代币
_balances[from] = 10000000;

//from地址减去转账数量
_balances[from] -= amount;
//to地址加上转账数量
_balances[to] += amount;

//释放事件
emit Transfer(from, to, amount);
}
}

事件

Solidity中的事件(event)是EVM上日志的抽象,它具有两个特点:

  • 响应:应用程序(ethers.js)可以通过RPC接口订阅和监听这些事件,并在前端做响应。
  • 经济:事件是EVM上比较经济的存储数据的方式,每个大概消耗2,000 gas;相比之下,链上存储一个新变量至少需要20,000 gas

声明事件

事件的声明由event关键字开头,接着是事件名称,括号里面写好事件需要记录的变量类型和变量名。

ERC20代币合约的Transfer事件为例:

1
event Transfer(address indexed from, address indexed to, uint256 value);

我们可以看到,Transfer事件共记录了3个变量fromtovalue,分别对应代币的转账地址,接收地址和转账数量,其中fromto前面带有indexed关键字,他们会保存在以太坊虚拟机日志的topics中,方便之后检索。

释放事件

我们可以在函数里释放事件。在下面的例子中,每次用_transfer()函数进行转账操作的时候,都会释放Transfer事件,并记录相应的变量。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// 定义_transfer函数,执行转账逻辑
function _transfer(
address from,
address to,
uint256 amount
) external {

_balances[from] = 10000000; // 给转账地址一些初始代币

_balances[from] -= amount; // from地址减去转账数量
_balances[to] += amount; // to地址加上转账数量

// 释放事件
emit Transfer(from, to, amount);
}

EVM日志(Log)

以太坊虚拟机(EVM)用日志Log来存储Solidity事件,每条日志记录都包含主题topics和数据data两部分。

image-20250126183129690

主题(Topics)

日志的第一部分是主题数组,用于描述事件,长度不能超过4。它的第一个元素是事件的签名(哈希)。

对于上面的Transfer事件,它的事件哈希就是:

1
2
3
keccak256("Transfer(address,address,uint256)")

//0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef

除了事件哈希,主题还可以包含至多3indexed参数,也就是Transfer事件中的fromto

indexed标记的参数可以理解为检索事件的索引“键”,方便之后搜索。每个 indexed 参数的大小为固定的256比特,如果参数太大了(比如字符串),就会自动计算哈希存储在主题中。

这里其实会引入一个新的问题,根据Solidity的官方文档, 对于非值类型的参数(如arrays, bytes, strings), Solidity不会直接存储,而是会将Keccak-256哈希存储在主题中,从而导致数据信息的丢失。这对于某些依赖于链上事件的DAPP(跨链,用户注册等等)来说,可能会导致事件检索困难,需要解析哈希值。

数据(Data)

事件中不带 indexed的参数会被存储在 data 部分中,可以理解为事件的"值"。data 部分的变量不能被直接检索,但可以存储任意大小的数据。因此一般 data 部分可以用来存储复杂的数据结构,例如数组和字符串等等,因为这些数据超过了256比特,即使存储在事件的 topics 部分中,也是以哈希的方式存储。另外,data 部分的变量在存储上消耗的gas相比于 topics 更少。

image-20250126185114449

Topics里面有三个元素,第一个是这个事件的哈希,第二和三是我们定义的两个indexed变量的信息,即转账的转出地址和接收地址。Data里面是剩下的不带indexed的变量,也就是转账数量。可以在上图看到Data部分为0x32,50的16进制。

继承

这一讲,我们介绍Solidity中的继承(inheritance),包括简单继承,多重继承,以及修饰器(Modifier)和构造函数(Constructor)的继承。

继承

继承是面向对象编程很重要的组成部分,可以显著减少重复代码。如果把合约看作是对象的话,Solidity也是面向对象的编程,也支持继承。

规则

  • virtual: 父合约中的函数,如果希望子合约重写,需要加上virtual关键字。
  • override子合约重写了父合约中的函数,需要加上override关键字

注意:用override修饰public变量,会重写与变量同名的getter函数,例如:

1
mapping(address => uint256) public override balanceOf;

简单继承

我们先写一个简单的爷爷合约Yeye,里面包含1个Log事件和3个function: hip(), pop(), yeye(),输出都是”Yeye”。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
contract Yeye {
event Log(string msg);

// 定义3个function: hip(), pop(), yeye(),Log值为Yeye。
function hip() public virtual{
emit Log("Yeye");
}

function pop() public virtual{
emit Log("Yeye");
}

function yeye() public virtual {
emit Log("Yeye");
}
}

我们再定义一个爸爸合约Baba,让他继承Yeye合约,语法就是contract Baba is Yeye,非常直观。在Baba合约里,我们重写一下hip()pop()这两个函数,加上override关键字,并将他们的输出改为”Baba”;并且加一个新的函数baba,输出也是”Baba”

1
2
3
4
5
6
7
8
9
10
11
12
13
14
contract Baba is Yeye{
// 继承两个function: hip()和pop(),输出改为Baba。
function hip() public virtual override{
emit Log("Baba");
}

function pop() public virtual override{
emit Log("Baba");
}

function baba() public virtual{
emit Log("Baba");
}
}

完整的测试代码Yeye.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
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.21;
contract Yeye{
event Log(string msg);
// 定义3个function: hip(), pop(), yeye(),Log值为Yeye。
function hip() public virtual{
emit Log("Yeye");
}

function pop() public virtual{
emit Log("Yeye");
}

function yeye() public virtual {
emit Log("Yeye");
}
}

contract Baba is Yeye{
// 继承两个function: hip()和pop(),输出改为Baba。
function hip() public virtual override{
emit Log("Baba");
}

function pop() public virtual override{
emit Log("Baba");
}

function baba() public virtual{
emit Log("Baba");
}
}

image-20250126213523593

多重继承

Solidity的合约可以继承多个合约。规则:

  1. 继承时要按辈分最高到最低的顺序排。比如我们写一个Erzi合约,继承Yeye合约和Baba合约,那么就要写成contract Erzi is Yeye, Baba,而不能写成contract Erzi is Baba, Yeye,不然就会报错。
  2. 如果某一个函数在多个继承的合约里都存在,比如例子中的hip()pop(),在子合约里必须重写,不然会报错。
  3. 重写在多个父合约中都重名的函数时,override关键字后面要加上所有父合约名字,例如override(Yeye, Baba)

例子:

1
2
3
4
5
6
7
8
9
10
contract Erzi is Yeye, Baba{
// 继承两个function: hip()和pop(),输出值为Erzi。
function hip() public virtual override(Yeye, Baba){
emit Log("Erzi");
}

function pop() public virtual override(Yeye, Baba) {
emit Log("Erzi");
}
}

我们可以看到,Erzi合约里面重写了hip()pop()两个函数,将输出改为”Erzi”,并且还分别从YeyeBaba合约继承了yeye()baba()两个函数。

完整的测试代码Yeye.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
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.21;
contract Yeye{
event Log(string msg);
// 定义3个function: hip(), pop(), yeye(),Log值为Yeye。
function hip() public virtual{
emit Log("Yeye");
}

function pop() public virtual{
emit Log("Yeye");
}

function yeye() public virtual {
emit Log("Yeye");
}
}

contract Baba is Yeye{
// 继承两个function: hip()和pop(),输出改为Baba。
function hip() public virtual override{
emit Log("Baba");
}

function pop() public virtual override{
emit Log("Baba");
}

function baba() public virtual{
emit Log("Baba");
}
}

contract Erzi is Yeye, Baba{
// 继承两个function: hip()和pop(),输出值为Erzi。
function hip() public virtual override(Yeye, Baba){
emit Log("Erzi");
}

function pop() public virtual override(Yeye, Baba) {
emit Log("Erzi");
}
}

image-20250126213416543

修饰器的继承

Solidity中的修饰器(Modifier)同样可以继承,用法与函数继承类似,在相应的地方加virtualoverride关键字即可。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
contract Base1 {
modifier exactDividedBy2And3(uint _a) virtual {
require(_a % 2 == 0 && _a % 3 == 0);
_;
}
}

contract Identifier is Base1 {

//计算一个数分别被2除和被3除的值,但是传入的参数必须是2和3的倍数
function getExactDividedBy2And3(uint _dividend) public exactDividedBy2And3(_dividend) pure returns(uint, uint) {
return getExactDividedBy2And3WithoutModifier(_dividend);
}

//计算一个数分别被2除和被3除的值
function getExactDividedBy2And3WithoutModifier(uint _dividend) public pure returns(uint, uint){
uint div2 = _dividend / 2;
uint div3 = _dividend / 3;
return (div2, div3);
}
}

Identifier合约可以直接在代码中使用父合约中的exactDividedBy2And3修饰器,也可以利用override关键字重写修饰器:

1
2
3
4
modifier exactDividedBy2And3(uint _a) override {
_;
require(_a % 2 == 0 && _a % 3 == 0);
}

image-20250126214328979

完整的测试代码Base1.sol,代码如下:

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.4;
contract Base1 {
modifier exactDividedBy2And3(uint _a) virtual {
require(_a % 2 == 0 && _a % 3 == 0);
_;
}
}

contract Identifier is Base1 {

//计算一个数分别被2除和被3除的值,但是传入的参数必须是2和3的倍数
function getExactDividedBy2And3(uint _dividend) public exactDividedBy2And3(_dividend) pure returns(uint, uint) {
return getExactDividedBy2And3WithoutModifier(_dividend);
}

//计算一个数分别被2除和被3除的值
function getExactDividedBy2And3WithoutModifier(uint _dividend) public pure returns(uint, uint){
uint div2 = _dividend / 2;
uint div3 = _dividend / 3;
return (div2, div3);
}
}

构造函数的继承

子合约有两种方法继承父合约的构造函数。举个简单的例子,父合约A里面有一个状态变量a,并由构造函数的参数来确定:

1
2
3
4
5
6
7
8
// 构造函数的继承
abstract contract A {
uint public a;

constructor(uint _a) {
a = _a;
}
}
  1. 在继承时声明父构造函数的参数,例如:contract B is A(6)
  2. 在子合约的构造函数中声明构造函数的参数,例如:
1
2
3
contract C is A {
constructor(uint _c) A(_c * _c) {}
}

完整的测试代码A.sol如下:

1
2
3
4
5
6
7
8
9
10
11
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.4;
contract A{
uint public a;
constructor(uint _a) {
a = _a;
}
}
contract C is A {
constructor(uint _c) A(_c * _c) {}
}

image-20250126214956006

调用父合约的函数

子合约有两种方式调用父合约的函数,直接调用和利用super关键字。

  1. 直接调用:子合约可以直接用父合约名.函数名()的方式来调用父合约函数,例如Yeye.pop()
1
2
3
function callParent() public{
Yeye.pop();
}
  1. super关键字:子合约可以利用super.函数名()来调用最近的父合约函数。Solidity继承关系按声明时从右到左的顺序是:contract Erzi is Yeye, Baba,那么Baba是最近的父合约,super.pop()将调用Baba.pop()而不是Yeye.pop()
1
2
3
4
function callParentSuper() public{
// 将调用最近的父合约函数,Baba.pop()
super.pop();
}

钻石继承

在面向对象编程中,钻石继承(菱形继承)指一个派生类同时有两个或两个以上的基类。

在多重+菱形继承链条上使用super关键字时,需要注意的是使用super会调用继承链条上的每一个合约的相关函数,而不是只调用最近的父合约。

我们先写一个合约God,再写AdamEve两个合约继承God合约,最后让创建合约people继承自AdamEve,每个合约都有foobar两个函数。

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

/* 继承树:
God
/ \
Adam Eve
\ /
people
*/

contract God {
event Log(string message);

function foo() public virtual {
emit Log("God.foo called");
}

function bar() public virtual {
emit Log("God.bar called");
}
}

contract Adam is God {
function foo() public virtual override {
emit Log("Adam.foo called");
super.foo();
}

function bar() public virtual override {
emit Log("Adam.bar called");
super.bar();
}
}

contract Eve is God {
function foo() public virtual override {
emit Log("Eve.foo called");
super.foo();
}

function bar() public virtual override {
emit Log("Eve.bar called");
super.bar();
}
}

contract people is Adam, Eve {
function foo() public override(Adam, Eve) {
super.foo();
}

function bar() public override(Adam, Eve) {
super.bar();
}
}

在这个例子中,调用合约people中的super.bar()会依次调用EveAdam,最后是God合约。

虽然EveAdam都是God的子合约,但整个过程中God合约只会被调用一次。原因是Solidity借鉴了Python的方式,强制一个由基类构成的DAG(有向无环图)使其保证一个特定的顺序。更多细节你可以查阅Solidity的官方文档

image-20250126223941045

抽象合约和接口

这一讲,我们用ERC721的接口合约为例介绍Solidity中的抽象合约(abstract)和接口(interface),帮助大家更好的理解ERC721标准。

ERC-721是以太坊网络上用于实现非同质化代币(NFT)的标准。它定义了一组规则,允许创建具有唯一性的数字资产或代币。与ERC-20标准的同质化代币不同ERC-721代币每一个都是独一无二的,可以用来表示艺术作品、收藏品、游戏道具、房地产等独特的资产。

抽象合约

如果一个智能合约里至少有一个未实现的函数,即某个函数缺少主体{}中的内容,则必须将该合约标为abstract,不然编译会报错;另外,未实现的函数需要加virtual,以便子合约重写。如果我们还没想好具体怎么实现某个函数,那么可以把合约标为abstract,之后让别人补写上。

1
2
3
abstract contract InsertionSort{
function insertionSort(uint[] memory a) public pure virtual returns(uint[] memory);
}

测试抽象合约的代码Abstract.sol,如下:

1
2
3
4
5
6
7
8
9
10
11
12
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.4;
abstract contract Base{
string public name = "Base";
function getAlias() public pure virtual returns(string memory);
}

contract BaseImpl is Base{
function getAlias() public pure override returns(string memory){
return "BaseImpl";
}
}

测试结果如下:

image-20250127032832688

接口

接口类似于抽象合约,但它不实现任何功能。接口的规则:

  1. 不能包含状态变量
  2. 不能包含构造函数
  3. 不能继承除接口外的其他合约
  4. 所有函数都必须是external且不能有函数体
  5. 继承接口的非抽象合约必须实现接口定义的所有功能

虽然接口不实现任何功能,但它非常重要。接口是智能合约的骨架,定义了合约的功能以及如何触发它们。

如果智能合约实现了某种接口(比如ERC20ERC721),其他Dapps和智能合约就知道如何与它交互。

因为接口提供了两个重要的信息:

  1. 合约里每个函数的bytes4选择器,以及函数签名函数名(每个参数类型)
  2. 接口id(更多信息见EIP165

另外,接口与合约ABI(Application Binary Interface)等价,可以相互转换:编译接口可以得到合约的ABI,利用abi-to-sol工具,也可以将ABI json文件转换为接口sol文件。

我们以ERC721接口合约IERC721为例,它定义了3个event和9个function,所有ERC721标准的NFT都实现了这些函数。我们可以看到,接口和常规合约的区别在于每个函数都以;代替函数体{ }结尾。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
interface IERC721 is IERC165 {
event Transfer(address indexed from, address indexed to, uint256 indexed tokenId);
event Approval(address indexed owner, address indexed approved, uint256 indexed tokenId);
event ApprovalForAll(address indexed owner, address indexed operator, bool approved);

function balanceOf(address owner) external view returns (uint256 balance);

function ownerOf(uint256 tokenId) external view returns (address owner);

function safeTransferFrom(address from, address to, uint256 tokenId) external;

function transferFrom(address from, address to, uint256 tokenId) external;

function approve(address to, uint256 tokenId) external;

function getApproved(uint256 tokenId) external view returns (address operator);

function setApprovalForAll(address operator, bool _approved) external;

function isApprovedForAll(address owner, address operator) external view returns (bool);

function safeTransferFrom( address from, address to, uint256 tokenId, bytes calldata data) external;
}

测试接口的代码Interface.sol,如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.4;
interface Base{
function getFirstName() external pure returns (string memory);
function getLastName() external pure returns (string memory);
}
contract BaseImpl is Base{
function getFirstName() external pure override returns(string memory){
return "Amazing";
}
function getLastName() external pure override returns(string memory){
return "Ang";
}
}

image-20250127121544577

IERC721事件

IERC721包含3个事件,其中TransferApproval事件在ERC20中也有。

  • Transfer事件:在转账时被释放,记录代币的发出地址from,接收地址totokenId
  • Approval事件:在授权时被释放,记录授权地址owner,被授权地址approvedtokenId
  • ApprovalForAll事件:在批量授权时被释放,记录批量授权的发出地址owner,被授权地址operator和授权与否的approved

IERC721函数

  • balanceOf:返回某地址的NFT持有量balance
  • ownerOf:返回某tokenId的主人owner
  • transferFrom:普通转账,参数为转出地址from,接收地址totokenId
  • safeTransferFrom:安全转账(如果接收方是合约地址,会要求实现ERC721Receiver接口)。参数为转出地址from,接收地址totokenId
  • approve:授权另一个地址使用你的NFT。参数为被授权地址approvetokenId
  • getApproved:查询tokenId被批准给了哪个地址。
  • setApprovalForAll:将自己持有的该系列NFT批量授权给某个地址operator
  • isApprovedForAll:查询某地址的NFT是否批量授权给了另一个operator地址。
  • safeTransferFrom:安全转账的重载函数,参数里面包含了data

什么时候使用接口?

如果我们知道一个合约实现了IERC721接口,我们不需要知道它具体代码实现,就可以与它交互。

无聊猿BAYC属于ERC721代币,实现了IERC721接口的功能。我们不需要知道它的源代码,只需知道它的合约地址,用IERC721接口就可以与它交互,比如用balanceOf()来查询某个地址的BAYC余额,用safeTransferFrom()来转账BAYC

1
2
3
4
5
6
7
8
9
10
11
12
13
14
contract interactBAYC {
// 利用BAYC地址创建接口合约变量(ETH主网)
IERC721 BAYC = IERC721(0xBC4CA0EdA7647A8aB7C2061c2E118A18a936f13D);

// 通过接口调用BAYC的balanceOf()查询持仓量
function balanceOfBAYC(address owner) external view returns (uint256 balance){
return BAYC.balanceOf(owner);
}

// 通过接口调用BAYC的safeTransferFrom()安全转账
function safeTransferFromBAYC(address from, address to, uint256 tokenId) external{
BAYC.safeTransferFrom(from, to, tokenId);
}
}

异常

这一讲,我们介绍Solidity三种抛出异常的方法:errorrequireassert,并比较三种方法的gas消耗。

异常

写智能合约经常会出bugSolidity中的异常命令帮助我们debug

测试代码Error.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
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.21;

// Gas cost在Remix中测试得到 使用0.8.26版本编译
// 参数使用 tokenId = 123, address = {any address}

// 自定义error
//error TransferNotOwner();

error TransferNotOwner(address sender);

contract Errors {
// 一组映射,记录每个TokenId的Owner
mapping(uint256 => address) private _owners;

// Error方法: gas cost 24095
// Error方法: gas cost 24113
function transferOwner1(uint256 tokenId, address newOwner) public {
if (_owners[tokenId] != msg.sender) {
//revert TransferNotOwner();
revert TransferNotOwner(msg.sender);
}
_owners[tokenId] = newOwner;
}

// require方法: gas cost 24211
function transferOwner2(uint256 tokenId, address newOwner) public {
require(_owners[tokenId] == msg.sender, "Transfer Not Owner");
_owners[tokenId] = newOwner;
}

// assert方法: gas cost 24109
function transferOwner3(uint256 tokenId, address newOwner) public {
assert(_owners[tokenId] == msg.sender);
_owners[tokenId] = newOwner;
}
}

Error

errorsolidity 0.8.4版本新加的内容,方便且高效(省gas)地向用户解释操作失败的原因,同时还可以在抛出异常的同时携带参数,帮助开发者更好地调试。人们可以在contract之外定义异常。下面,我们定义一个TransferNotOwner异常,当用户不是代币owner的时候尝试转账,会抛出错误:

1
error TransferNotOwner(); // 自定义error

我们也可以定义一个携带参数的异常,来提示尝试转账的账户地址

1
error TransferNotOwner(address sender); // 自定义的带参数的error

在执行当中,error必须搭配revert(回退)命令使用。

1
2
3
4
5
6
7
function transferOwner1(uint256 tokenId, address newOwner) public {
if(_owners[tokenId] != msg.sender){
revert TransferNotOwner();
// revert TransferNotOwner(msg.sender);
}
_owners[tokenId] = newOwner;
}

我们定义了一个transferOwner1()函数,它会检查代币的owner是不是发起人,如果不是,就会抛出TransferNotOwner异常;如果是的话,就会转账。

不带参数的error,消耗gas量为24095

image-20250127123837474

带参数的error,消耗gas量为24113

image-20250127124300009

Require

require命令是solidity 0.8版本之前抛出异常的常用方法,目前很多主流合约仍然还在使用它。它很好用,唯一的缺点就是gas随着描述异常的字符串长度增加,比error命令要高。使用方法:require(检查条件,"异常的描述")当检查条件不成立的时候,就会抛出异常。

我们用require命令重写一下上面的transferOwner1函数:

1
2
3
4
function transferOwner2(uint256 tokenId, address newOwner) public {
require(_owners[tokenId] == msg.sender, "Transfer Not Owner");
_owners[tokenId] = newOwner;
}

require消耗gas的量为24211

image-20250127125601300

Assert

assert命令一般用于程序员写程序debug,因为它不能解释抛出异常的原因(比require少个字符串)。它的用法很简单,assert(检查条件)当检查条件不成立的时候,就会抛出异常。

我们用assert命令重写一下上面的transferOwner1函数:

1
2
3
4
function transferOwner3(uint256 tokenId, address newOwner) public {
assert(_owners[tokenId] == msg.sender);
_owners[tokenId] = newOwner;
}

assert消耗gas的量为24109

image-20250127130047597

三种方法的gas比较

我们比较一下三种抛出异常的gas消耗,查询gas的消耗 (使用0.8.26版本编译)

  1. error方法gas消耗:24095 (加入参数后gas消耗:24113)
  2. require方法gas消耗:24211
  3. assert方法gas消耗:24109

我们可以看到,error方法gas最少,其次是assertrequire方法消耗gas最多!因此,error既可以告知用户抛出异常的原因,又能省gas,大家要多用!(注意,由于部署测试时间的不同,每个函数的gas消耗会有所不同,但是比较结果会是一致的。)

备注: Solidity 0.8.0之前的版本,assert抛出的是一个 panic exception,会把剩余的 gas 全部消耗,不会返还。更多细节见官方文档

参考

https://www.wtf.academy/docs/solidity-101/HelloWeb3

https://github.com/AmazingAng/WTF-Solidity

https://learnblockchain.cn/docs/ethers.js/api.html