学习内容参考WTF
学习笔记,方便回忆
目录
1 代码初步了解
Solidity
是一种用于编写以太坊虚拟机(EVM
)智能合约的编程语言。
Remix
是以太坊官方推荐的智能合约集成开发环境(IDE),适合新手,可以在浏览器中快速开发和部署合约,无需在本地安装任何程序。
第一个Solidity程序
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.21;
contract HelloWeb3{
string public _string = "Hello Web3!";
}
- 第 1 行是注释,说明代码所使用的软件许可(license),这里使用的是 MIT 许可。如果不写许可,编译时会出现警告(warning),但程序仍可运行。Solidity 注释以“//”开头,后面跟注释内容,注释不会被程序执行。
- 第 2 行声明源文件所使用的 Solidity 版本,因为不同版本的语法有差异。这行代码表示源文件将不允许小于 0.8.21 版本或大于等于 0.9.0 的编译器编译(第二个条件由
^
提供)。Solidity 语句以分号(;)结尾。 - 第 3-4 行是合约部分。第 3 行创建合约(contract),并声明合约名为
HelloWeb3
。第 4 行是合约内容,声明了一个 string(字符串)变量_string
,并赋值为 "Hello Web3!"。
在 Remix 编辑代码的页面,按 Ctrl + S 即可编译代码,非常方便。编译完成后,点击左侧菜单的“部署”按钮,进入部署页面。默认情况下,Remix
会使用 Remix
虚拟机(以前称为 JavaScript 虚拟机)来模拟以太坊链,运行智能合约,类似在浏览器里运行一条测试链。Remix
还会为你分配一些测试账户,每个账户里有 100 ETH(测试代币),随意使用。点击 Deploy
(黄色按钮),即可部署我们编写的合约。部署成功后,在下方会看到名为 HelloWeb3
的合约。点击 _string
,即可看到 "Hello Web3!"。
2 值类型
-
值类型(Value Type) :包括布尔型,整数型等等,这类变量赋值时候直接传递数值。
-
引用类型(Reference Type) :包括数组和结构体,这类变量占空间大,赋值时候直接传递地址(类似指针)。
-
映射类型(Mapping Type) : Solidity中存储键值对的数据结构,可以理解为哈希表
布尔型
布尔型是二值变量,取值为 true
或 false
。
!
(逻辑非)&&
(逻辑与,"and")||
(逻辑或,"or")==
(等于)!=
(不等于)
值得注意的是: &&
和 ||
运算符遵循短路规则,这意味着,假如存在 f(x) || g(y)
的表达式,如果 f(x)
是 true
,g(y)
不会被计算,即使它和 f(x)
的结果是相反的。假如存在f(x) && g(y)
的表达式,如果 f(x)
是 false
,g(y)
不会被计算。 所谓“短路规则”,一般出现在逻辑与(&&)和逻辑或(||)中。 当逻辑与(&&)的第一个条件为false时,就不会再去判断第二个条件; 当逻辑或(||)的第一个条件为true时,就不会再去判断第二个条件,这就是短路规则。
整型
// 整型
int public _int = -1; // 整数,包括负数
uint public _uint = 1; // 无符号整数
uint256 public _number = 20220330; // 256位无符号整数
- 比较运算符(返回布尔值):
<=
,<
,==
,!=
,>=
,>
- 算术运算符:
+
,-
,*
,/
,%
(取余),**
(幂)
地址类型
地址类型(address)有两类:
- 普通地址(address): 存储一个 20 字节的值(以太坊地址的大小)。
- payable address: 比普通地址多了
transfer
和send
两个成员方法,用于接收转账。
// 地址
address public _address = 0x7A58c0Be72BE218B41C608b7Fe7C5bB630736C71;
address payable public _address1 = payable(_address); // payable address,可以转账、查余额
// 地址类型的成员
uint256 public balance = _address1.balance; // balance of address
定长字节数组
字节数组分为定长和不定长两种:
- 定长字节数组: 属于值类型,数组长度在声明之后不能改变。根据字节数组的长度分为
bytes1
,bytes8
,bytes32
等类型。定长字节数组最多存储 32 bytes 数据,即bytes32
。 - 不定长字节数组: 属于引用类型(之后的章节介绍),数组长度在声明之后可以改变,包括
bytes
等。
// 固定长度的字节数组
bytes32 public _byte32 = "MiniSolidity";
bytes1 public _byte = _byte32[0];
在上述代码中,字符串 MiniSolidity
以字节的方式存储进变量 _byte32
。如果把它转换成 16 进制
,就是:0x4d696e69536f6c69646974790000000000000000000000000000000000000000
_byte
变量的值为 _byte32
的第一个字节,即 0x4d
。
枚举enum
枚举(enum
)是 Solidity 中用户定义的数据类型。它主要用于为 uint
分配名称,使程序易于阅读和维护。它与 C 语言
中的 enum
类似,使用名称来代替从 0
开始的 uint
:
枚举可以显式地和 uint
相互转换,并会检查转换的无符号整数是否在枚举的长度内,否则会报错:
// 用enum将uint 0, 1, 2表示为Buy, Hold, Sell
enum ActionSet { Buy, Hold, Sell }
// 创建enum变量 action
ActionSet action = ActionSet.Buy;
// enum可以和uint显式的转换
function enumToUint() external view returns(uint){
return uint(action);
}
3 函数
函数解析
function <function name>([parameter types[, ...]]) {internal|external|public|private} [pure|view|payable] [virtual|override] [<modifiers>]
[returns (<return types>)]{ <function body> }
方括号中的是可写可不 写的关键字
function
:声明函数时的固定用法。要编写函数,就需要以function
关键字开头。<function name>
:函数名。([parameter types[, ...]])
:圆括号内写入函数的参数,即输入到函数的变量类型和名称。{internal|external|public|private}
:函数可见性说明符,共有4种。-
[pure|view|payable]
:决定函数权限/功能的关键字。payable
(可支付的)很好理解,带着它的函数,运行的时候可以给合约转入 ETH。pure
和view
的介绍见下一节。 [virtual|override]
: 方法是否可以被重写,或者是否是重写方法。virtual
用在父合约上,标识的方法可以被子合约重写。override
用在自合约上,表名方法重写了父合约的方法。<modifiers>
: 自定义的修饰器,可以有0个或多个修饰器。[returns ()]
:函数返回的变量类型和名称。<function body>
: 函数体。
- 注意 1:合约中定义的函数需要明确指定可见性,它们没有默认值。
- 注意 2:
public|private|internal
也可用于修饰状态变量。public
变量会自动生成同名的getter
函数,用于查询数值。未标明可见性类型的状态变量,默认为internal
public
:内部和外部均可见。private
:只能从本合约内部访问,继承的合约也不能使用。external
:只能从合约外部访问(但内部可以通过this.f()
来调用,f
是函数名)。internal
: 只能从合约内部访问,继承的合约可以用。
Pure和View
solidity
引入这两个关键字主要是因为 以太坊交易需要支付气费(gas fee)。合约的状态变量存储在链上,gas fee 很贵,如果计算不改变链上状态,就可以不用付 gas
。包含 pure
和 view
关键字的函数是不改写链上状态的,因此用户直接调用它们是不需要付 gas 的(注意,合约中非 pure
/view
函数调用 pure
/view
函数时需要付gas)。
在以太坊中,以下语句被视为修改链上状态:
- 写入状态变量。
- 释放事件。
- 创建其他合约。
- 使用
selfdestruct
. - 通过调用发送以太币。
- 调用任何未标记
view
或pure
的函数。 - 使用低级调用(low-level calls)。
- 使用包含某些操作码的内联汇编。
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.21;
contract FunctionTypes{
uint256 public number = 5;
}
定义一个状态变量number,初始化为5
定义一个add()函数,每次调用会让number增加1
// 默认function
function add() external{
number = number + 1;
}
如果 add()
函数被标记为 pure
,比如 function add() external pure
,就会报错。因为 pure
是不配读取合约里的状态变量的,更不配改写。那 pure
函数能做些什么?举个例子,你可以给函数传递一个参数 _number
,然后让他返回 _number + 1
,这个操作不会读取或写入状态变量。
// pure: 纯纯牛马
function addPure(uint256 _number) external pure returns(uint256 new_number){
new_number = _number + 1;
}
如果 add()
函数被标记为 view
,比如 function add() external view
,也会报错。因为 view
能读取,但不能够改写状态变量。我们可以稍微改写下函数,读取但是不改写 number
,返回一个新的变量。
// view: 看客
function addView() external view returns(uint256 new_number) {
new_number = number + 1;
}
internal和external
// internal: 内部函数
function minus() internal {
number = number - 1;
}
// 合约内的函数可以调用内部函数
function minusCall() external {
minus();
}
我们定义一个 internal
的 minus()
函数,每次调用使得 number
变量减少 1。由于 internal
函数只能由合约内部调用,我们必须再定义一个 external
的 minusCall()
函数,通过它间接调用内部的 minus()
函数。
payable
// payable: 递钱,能给合约支付eth的函数
function minusPayable() external payable returns(uint256 balance) {
minus();
balance = address(this).balance;
}
我们定义一个 external payable
的 minusPayable()
函数,间接的调用 minus()
,并且返回合约里的 ETH 余额(this
关键字可以让我们引用合约地址)。我们可以在调用 minusPayable()
时往合约里转入1个 ETH。
4 函数输出
返回值:return和returns
returns
:跟在函数名后面,用于声明返回的变量类型及变量名。return
:用于函数主体中,返回指定的变量。
// 返回多个变量
function returnMultiple() public pure returns(uint256, bool, uint256[3] memory){
return(1, true, [uint256(1),2,5]);
}
命名式返回
在 returns
中标明返回变量的名称。Solidity 会初始化这些变量,并且自动返回这些变量的值,无需使用 return
。
// 命名式返回
function returnNamed() public pure returns(uint256 _number, bool _bool, uint256[3] memory _array){
_number = 2;
_bool = false;
_array = [uint256(3),2,1];
}
当然,你也可以在命名式返回中用 return
来返回变量:
// 命名式返回,依然支持return
function returnNamed2() public pure returns(uint256 _number, bool _bool, uint256[3] memory _array){
return(1, true, [uint256(1),2,5]);
}
解构式赋值
Solidity 支持使用解构式赋值规则来读取函数的全部或部分返回值。
读取所有返回值:声明变量,然后将要赋值的变量用,
隔开,按顺序排列。
uint256 _number;
bool _bool;
uint256[3] memory _array;
(_number, _bool, _array) = returnNamed();
读取部分返回值:声明要读取的返回值对应的变量,不读取的留空。在下面的代码中,我们只读取_bool
,而不读取返回的_number
和_array
:
(, _bool2, ) = returnNamed();
5 变量数据存储和作用域
引用类型(Reference Type) :包括数组(array
)和结构体(struct
),由于这类变量比较复杂,占用存储空间大,我们在使用时必须要声明数据存储的位置。
数据位置
Solidity数据存储位置有三类:storage
,memory
和calldata
。不同存储位置的gas
成本不同。storage
类型的数据存在链上,类似计算机的硬盘,消耗gas
多;memory
和calldata
类型的临时存在内存里,消耗gas
少。整体消耗gas
从多到少依次为:storage
> memory
> calldata
。大致用法:
storage
:合约里的状态变量默认都是storage
,存储在链上。memory
:函数里的参数和临时变量一般用memory
,存储在内存中,不上链。尤其是如果返回数据类型是变长的情况下,必须加memory修饰,例如:string, bytes, array和自定义结构calldata
:和memory
类似,存储在内存中,不上链。与memory
的不同点在于calldata
变量不能修改(immutable
),一般用于函数的参数。
例子:
function fCalldata(uint[] calldata _x) public pure returns(uint[] calldata){
//参数为calldata数组,不能被修改
// _x[0] = 0 //这样修改会报错
return(_x);
}
数据位置和赋值规则
在不同存储类型相互赋值时候,有时会产生独立的副本(修改新变量不会影响原变量),有时会产生引用(修改新变量会影响原变量)。规则如下:
赋值本质上是创建引用指向本体,因此修改本体或者是引用,变化可以被同步:
storage
(合约的状态变量)赋值给本地storage
(函数里的)时候,会创建引用,改变新变量会影响原变量。
uint[] x = [1,2,3]; // 状态变量:数组 x
function fStorage() public{
//声明一个storage的变量 xStorage,指向x。修改xStorage也会影响x
uint[] storage xStorage = x;
xStorage[0] = 100;
}
输出为:
memory
赋值给memory
,会创建引用,改变新变量会影响原变量。
其他情况下,赋值创建的是本体的副本,即对二者之一的修改,并不会同步到另一方。这有时会涉及到开发中的问题,比如从storage
中读取数据,赋值给memory
,然后修改memory
的数据,但如果没有将memory
的数据赋值回storage
,那么storage
的数据是不会改变的。
变量的作用域
Solidity
中变量按作用域划分有三种,分别是状态变量(state variable),局部变量(local variable)和全局变量(global variable)
1. 状态变量
状态变量是数据存储在链上的变量,所有合约内函数都可以访问,gas
消耗高。状态变量在合约内、函数外声明:
contract Variables {
uint public x = 1;
uint public y;
string public z;
}
//我们可以在函数里面更改状态变量的值:
function foo() external{
// 可以在函数里更改状态变量的值
x = 5;
y = 2;
z = "0xAA";
}
2. 局部变量
局部变量是仅在函数执行过程中有效的变量,函数退出后,变量无效。局部变量的数据存储在内存里,不上链,gas
低。局部变量在函数内声明:
function bar() external pure returns(uint){
uint xx = 1;
uint yy = 3;
uint zz = xx + yy;
return(zz);
}
3. 全局变量
全局变量是全局范围工作的变量,都是solidity
预留关键字。他们可以在函数内不声明直接使用:
function global() external view returns(address, uint, bytes memory){
address sender = msg.sender;
uint blockNum = block.number;
bytes memory data = msg.data;
return(sender, blockNum, data);
}
在上面例子里,我们使用了3个常用的全局变量:msg.sender
,block.number
和msg.data
,他们分别代表请求发起地址,当前区块高度,和请求数据。下面是一些常用的全局变量
blockhash(uint blockNumber)
: (bytes32
) 给定区块的哈希值 – 只适用于最近的256个区块, 不包含当前区块。block.coinbase
: (address payable
) 当前区块矿工的地址block.gaslimit
: (uint
) 当前区块的gaslimitblock.number
: (uint
) 当前区块的numberblock.timestamp
: (uint
) 当前区块的时间戳,为unix纪元以来的秒gasleft()
: (uint256
) 剩余 gasmsg.data
: (bytes calldata
) 完整call datamsg.sender
: (address payable
) 消息发送者 (当前 caller)msg.sig
: (bytes4
) calldata的前四个字节 (function identifier)msg.value
: (uint
) 当前交易发送的wei
值block.blobbasefee
: (uint
) 当前区块的blob基础费用。这是Cancun升级新增的全局变量。blobhash(uint index)
: (bytes32
) 返回跟当前交易关联的第index
个blob的版本化哈希(第一个字节为版本号,当前为0x01
,后面接KZG承诺的SHA256哈希的最后31个字节)。若当前交易不包含blob,则返回空字节。这是Cancun升级新增的全局变量。
4. 全局变量-以太单位与时间单位
以太单位
Solidity
中不存在小数点,以0
代替为小数点,来确保交易的精确度,并且防止精度的损失,利用以太单位可以避免误算的问题,方便程序员在合约中处理货币交易。
wei
: 1gwei
: 1e9 = 1000000000ether
: 1e18 = 1000000000000000000
function weiUnit() external pure returns(uint) {
assert(1 wei == 1e0);
assert(1 wei == 1);
return 1 wei;
}
function gweiUnit() external pure returns(uint) {
assert(1 gwei == 1e9);
assert(1 gwei == 1000000000);
return 1 gwei;
}
function etherUnit() external pure returns(uint) {
assert(1 ether == 1e18);
assert(1 ether == 1000000000000000000);
return 1 ether;
}
时间单位
可以在合约中规定一个操作必须在一周内完成,或者某个事件在一个月后发生。这样就能让合约的执行可以更加精确,不会因为技术上的误差而影响合约的结果。因此,时间单位在Solidity
中是一个重要的概念,有助于提高合约的可读性和可维护性。
seconds
: 1minutes
: 60 seconds = 60hours
: 60 minutes = 3600days
: 24 hours = 86400weeks
: 7 days = 604800
function secondsUnit() external pure returns(uint) {
assert(1 seconds == 1);
return 1 seconds;
}
function minutesUnit() external pure returns(uint) {
assert(1 minutes == 60);
assert(1 minutes == 60 seconds);
return 1 minutes;
}
function hoursUnit() external pure returns(uint) {
assert(1 hours == 3600);
assert(1 hours == 60 minutes);
return 1 hours;
}
function daysUnit() external pure returns(uint) {
assert(1 days == 86400);
assert(1 days == 24 hours);
return 1 days;
}
function weeksUnit() external pure returns(uint) {
assert(1 weeks == 604800);
assert(1 weeks == 7 days);
return 1 weeks;
}
6 引用类型
Solidity
中的两个重要变量类型:数组(array
)和结构体(struct
)。
数组array
数组(Array
)是Solidity
常用的一种变量类型,用来存储一组数据(整数,字节,地址等等)。数组分为固定长度数组和可变长度数组两种:
- 固定长度数组:在声明时指定数组的长度。用
T[k]
的格式声明,其中T
是元素的类型,k
是长度。 - 可变长度数组(动态数组):在声明时不指定数组的长度。用
T[]
的格式声明,其中T
是元素的类型,
// 固定长度 Array
uint[8] array1;
bytes1[5] array2;
address[100] array3;
// 可变长度 Array
uint[] array4;
bytes1[] array5;
address[] array6;
bytes array7;
bytes
比较特殊,是数组,但是不用加[]
。另外,不能用byte[]
声明单字节数组,可以使用bytes
或bytes1[]
。bytes
比 bytes1[]
省gas。
创建数组的规则
对于memory
修饰的动态数组
,可以用new
操作符来创建,但是必须声明长度,并且声明后长度不能改变。
// memory动态数组
uint[] memory array8 = new uint[](5);
bytes memory array9 = new bytes(9);
数组字面常数(Array Literals)是写作表达式形式的数组,用方括号包着来初始化array的一种方式,并且里面每一个元素的type是以第一个元素为准的,例如[1,2,3]
里面所有的元素都是uint8
类型,因为在Solidity中,如果一个值没有指定type的话,会根据上下文推断出元素的类型,默认就是最小单位的type,这里默认最小单位类型是uint8
。而[uint(1),2,3]
里面的元素都是uint
类型,因为第一个元素指定了是uint
类型了,里面每一个元素的type都以第一个元素为准。下面的例子中,如果没有对传入 g()
函数的数组进行 uint
转换,是会报错的。
// SPDX-License-Identifier: GPL-3.0
pragma solidity >=0.4.16 <0.9.0;
contract C {
function f() public pure {
g([uint(1), 2, 3]);
}
function g(uint[3] memory _data) public pure {
// ...
}
}
如果创建的是动态数组,你需要一个一个元素的赋值。
uint[] memory x = new uint[](3);
x[0] = 1;
x[1] = 3;
x[2] = 4;
数组成员
length
: 数组有一个包含元素数量的length
成员,memory
数组的长度在创建后是固定的。push()
:动态数组
拥有push()
成员,可以在数组最后添加一个0
元素,并返回该元素的引用。push(x)
:动态数组
拥有push(x)
成员,可以在数组最后添加一个x
元素。pop()
:动态数组
拥有pop()
成员,可以移除数组最后一个元素。
结构体struct
Solidity
支持通过构造结构体的形式定义新的类型。结构体中的元素可以是原始类型,也可以是引用类型;结构体可以作为数组或映射的元素。创建结构体的方法:
// 结构体
struct Student{
uint256 id;
uint256 score;
}
Student student; // 初始一个student结构体
给结构体赋值的四种方法:
// 给结构体赋值
// 方法1:在函数中创建一个storage的struct引用
function initStudent1() external{
Student storage _student = student; // assign a copy of student
_student.id = 11;
_student.score = 100;
}
// 方法2:直接引用状态变量的struct
function initStudent2() external{
student.id = 1;
student.score = 80;
}
// 方法3:构造函数式
function initStudent3() external {
student = Student(3, 90);
}
// 方法4:key value
function initStudent4() external {
student = Student({id: 4, score: 60});
}
7 映射类型
映射(Mapping
)类型,Solidity中存储键值对的数据结构,可以理解为哈希表。
映射mapping
在映射中,人们可以通过键(Key
)来查询对应的值(Value
),比如:通过一个人的id
来查询他的钱包地址。
声明映射的格式为mapping(_KeyType => _ValueType)
,其中_KeyType
和_ValueType
分别是Key
和Value
的变量类型。例子:
mapping(uint => address) public idToAddress; // id映射到地址
mapping(address => address) public swapPair; // 币对的映射,地址到地址
映射的规则
规则1:映射的_KeyType
只能选择Solidity内置的值类型,比如uint
,address
等,不能用自定义的结构体。而_ValueType
可以使用自定义的类型。下面这个例子会报错,因为_KeyType
使用了我们自定义的结构体:
// 我们定义一个结构体 Struct
struct Student{
uint256 id;
uint256 score;
}
mapping(Student => uint) public testVar;
规则2:映射的存储位置必须是storage
,因此可以用于合约的状态变量,函数中的storage
变量和library函数的参数。不能用于public
函数的参数或返回结果中,因为mapping
记录的是一种关系 (key - value pair)。
规则3:如果映射声明为public
,那么Solidity会自动给你创建一个getter
函数,可以通过Key
来查询对应的Value
规则4:给映射新增的键值对的语法为_Var[_Key] = _Value
,其中_Var
是映射变量名,_Key
和_Value
对应新增的键值对。例子:
function writeMap (uint _Key, address _Value) public{
idToAddress[_Key] = _Value;
}
映射的原理
- 原理1: 映射不储存任何键(
Key
)的资讯,也没有length的资讯。 - 原理2: 对于映射使用
keccak256(h(key) . slot)
计算存取value的位置。 - 原理3: 因为Ethereum会定义所有未使用的空间为0,所以未赋值(
Value
)的键(Key
)初始值都是各个type的默认值,如uint的默认值是0。
8 变量初始值
在Solidity
中,声明但没赋值的变量都有它的初始值或默认值。
值类型初始值
boolean
:false
string
:""
int
:0
uint
:0
enum
: 枚举中的第一个元素address
:0x0000000000000000000000000000000000000000
(或address(0)
)function
internal
: 空白函数external
: 空白函数
可以用public
变量的getter
函数验证上面写的初始值是否正确:
bool public _bool; // false
string public _string; // ""
int public _int; // 0
uint public _uint; // 0
address public _address; // 0x0000000000000000000000000000000000000000
enum ActionSet { Buy, Hold, Sell}
ActionSet public _enum; // 第1个内容Buy的索引0
function fi() internal{} // internal空白函数
function fe() external{} // external空白函数
引用类型初始值
- 映射
mapping
: 所有元素都为其默认值的mapping
- 结构体
struct
: 所有成员设为其默认值的结构体 - 数组
array
- 动态数组:
[]
- 静态数组(定长): 所有成员设为其默认值的静态数组
- 动态数组:
可以用public
变量的getter
函数验证上面写的初始值是否正确:
// Reference Types
uint[8] public _staticArray; // 所有成员设为其默认值的静态数组[0,0,0,0,0,0,0,0]
uint[] public _dynamicArray; // `[]`
mapping(uint => address) public _mapping; // 所有元素都为其默认值的mapping
// 所有成员设为其默认值的结构体 0, 0
struct Student{
uint256 id;
uint256 score;
}
Student public student;
delete操作符
delete a
会让变量a
的值变为初始值。
// delete操作符
bool public _bool2 = true;
function d() external {
delete _bool2; // delete 会让_bool2变为默认值,false
}
9 常数
constant
(常量)和immutable
(不变量)。状态变量声明这两个关键字之后,不能在初始化后更改数值。这样做的好处是提升合约的安全性并节省gas
。
另外,只有数值变量可以声明constant
和immutable
;string
和bytes
可以声明为constant
,但不能为immutable
。
constant和immutable
constant
constant
变量必须在声明的时候初始化,之后再也不能改变。尝试改变的话,编译不通过。
// constant变量必须在声明的时候初始化,之后不能改变
uint256 constant CONSTANT_NUM = 10;
string constant CONSTANT_STRING = "0xAA";
bytes constant CONSTANT_BYTES = "WTF";
address constant CONSTANT_ADDRESS = 0x0000000000000000000000000000000000000000;
immutable
immutable
变量可以在声明时或构造函数中初始化,因此更加灵活。在Solidity v0.8.21
以后,immutable
变量不需要显式初始化,未显式初始化的immutable
变量将使用数值类型的初始值。反之,则需要显式初始化。 若immutable
变量既在声明时初始化,又在constructor中初始化,会使用constructor初始化的值。
// immutable变量可以在constructor里初始化,之后不能改变
uint256 public immutable IMMUTABLE_NUM = 9999999999;
// 在`Solidity v8.0.21`以后,下列变量数值暂为初始值
address public immutable IMMUTABLE_ADDRESS;
uint256 public immutable IMMUTABLE_BLOCK;
uint256 public immutable IMMUTABLE_TEST;
你可以使用全局变量例如address(this)
,block.number
或者自定义的函数给immutable
变量初始化。在下面这个例子,我们利用了test()
函数给IMMUTABLE_TEST
初始化为9
:
// 利用constructor初始化immutable变量,因此可以利用
constructor(){
IMMUTABLE_ADDRESS = address(this);
IMMUTABLE_NUM = 1118;
IMMUTABLE_TEST = test();
}
function test() public pure returns(uint256){
uint256 what = 9;
return(what);
}
10 控制流
Solidity
中的控制流,然后讲如何用Solidity
实现插入排序(InsertionSort
),一个看起来简单,但实际上很容易写出bug
的程序。
控制流
Solidity
的控制流与其他语言类似,主要包含以下几种:
1、if-else
function ifElseTest(uint256 _number) public pure returns(bool){
if(_number == 0){
return(true);
}else{
return(false);
}
}
2、for循环
function forLoopTest() public pure returns(uint256){
uint sum = 0;
for(uint i = 0; i < 10; i++){
sum += i;
}
return(sum);
}
3、while循环
function whileTest() public pure returns(uint256){
uint sum = 0;
uint i = 0;
while(i < 10){
sum += i;
i++;
}
return(sum);
}
4、do-while循环
function doWhileTest() public pure returns(uint256){
uint sum = 0;
uint i = 0;
do{
sum += i;
i++;
}while(i < 10);
return(sum);
}
5、三元运算符是Solidity中唯一一个接受三个操作数的运算符,规则条件? 条件为真的表达式:条件为假的表达式;
。此运算符经常用作if
语句的快捷方式。
// 三元运算符 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
(跳出当前循环)关键字可以使用。
用Solidity
实现插入排序
插入排序
排序算法解决的问题是将无序的一组数字,例如[2, 5, 3, 1]
,从小到大依次排列好。插入排序(InsertionSort
)是最简单的一种排序算法,也是很多人学习的第一个算法。它的思路很简单,从前往后,依次将每一个数和排在他前面的数字比大小,如果比前面的数字小,就互换位置。
python代码
# Python program for implementation of Insertion Sort
def insertionSort(arr):
for i in range(1, len(arr)):
key = arr[i]
j = i-1
while j >=0 and key < arr[j] :
arr[j+1] = arr[j]
j -= 1
arr[j+1] = key
return arr
Solidity
中最常用的变量类型是uint
,也就是无符号整数,取到负值的话,会报underflow
错误。而在插入算法中,变量j
有可能会取到-1
,引起报错。这里,我们需要把j
加1,让它无法取到负值。正确代码:
// 插入排序 正确版
function insertionSort(uint[] memory a) public pure returns(uint[] memory) {
// note that uint can not take negative value
for (uint i = 1;i < a.length;i++){
uint temp = a[i];
uint j=i;
while( (j >= 1) && (temp < a[j-1])){
a[j] = a[j-1];
j--;
}
a[j] = temp;
}
return(a);
}
11 构造函数和修饰器
用合约权限控制(Ownable
)的例子介绍Solidity
语言中构造函数(constructor
)和独有的修饰器(modifier
)。
构造函数
构造函数(constructor
)是一种特殊的函数,每个合约可以定义一个,并在部署合约的时候自动运行一次。它可以用来初始化合约的一些参数,例如初始化合约的owner
地址:
address owner; // 定义owner变量
// 构造函数
constructor(address initialOwner) {
owner = initialOwner; // 在部署合约的时候,将owner设置为传入的initialOwner地址
}
注意:构造函数在不同的Solidity版本中的语法并不一致,在Solidity 0.4.22之前,构造函数不使用 constructor
而是使用与合约名同名的函数作为构造函数而使用,由于这种旧写法容易使开发者在书写时发生疏漏(例如合约名叫 Parents
,构造函数名写成 parents
),使得构造函数变成普通函数,引发漏洞,所以0.4.22版本及之后,采用了全新的 constructor
写法。
修饰器
修饰器(modifier
)是Solidity
特有的语法,类似于面向对象编程中的装饰器(decorator
),声明函数拥有的特性,并减少代码冗余。modifier
的主要使用场景是运行函数前的检查,例如地址,变量,余额等。
我们来定义一个叫做onlyOwner的modifier:
// 定义modifier
modifier onlyOwner {
require(msg.sender == owner); // 检查调用者是否为owner地址
_; // 如果是的话,继续运行函数主体;否则报错并revert交易
}
带有onlyOwner
修饰符的函数只能被owner
地址调用,比如下面这个例子:
function changeOwner(address _newOwner) external onlyOwner{
owner = _newOwner; // 只有owner地址运行这个函数,并改变owner
}
我们定义了一个changeOwner
函数,运行它可以改变合约的owner
,但是由于onlyOwner
修饰符的存在,只有原先的owner
可以调用,别人调用就会报错。这也是最常用的控制智能合约权限的方法。
OpenZeppelin的Ownable标准实现
OpenZeppelin
是一个维护Solidity
标准化代码库的组织,他的Ownable
标准实现如下: https://github.com/OpenZeppelin/openzeppelin-contracts/blob/master/contracts/access/Ownable.sol
12 事件
用转账ERC20代币为例来介绍Solidity
中的事件(event
)。
事件
Solidity
中的事件(event
)是EVM
上日志的抽象,它具有两个特点:
- 响应:应用程序(ethers.js)可以通过
RPC
接口订阅和监听这些事件,并在前端做响应。 - 经济:事件是
EVM
上比较经济的存储数据的方式,每个大概消耗2,000gas
;相比之下,链上存储一个新变量至少需要20,000gas
。
声明事件
事件的声明由event
关键字开头,接着是事件名称,括号里面写好事件需要记录的变量类型和变量名。以ERC20
代币合约的Transfer
事件为例:
event Transfer(address indexed from, address indexed to, uint256 value);
我们可以看到,Transfer
事件共记录了3个变量from
,to
和value
,分别对应代币的转账地址,接收地址和转账数量,其中from
和to
前面带有indexed
关键字,他们会保存在以太坊虚拟机日志的topics
中,方便之后检索。
释放事件
我们可以在函数里释放事件。在下面的例子中,每次用_transfer()
函数进行转账操作的时候,都会释放Transfer
事件,并记录相应的变量。
// 定义_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
两部分。
主题 topics
日志的第一部分是主题数组,用于描述事件,长度不能超过4
。它的第一个元素是事件的签名(哈希)。对于上面的Transfer
事件,它的事件哈希就是:
keccak256("Transfer(address,address,uint256)")
//0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef
除了事件哈希,主题还可以包含至多3
个indexed
参数,也就是Transfer
事件中的from
和to
。
indexed
标记的参数可以理解为检索事件的索引“键”,方便之后搜索。每个 indexed
参数的大小为固定的256比特,如果参数太大了(比如字符串),就会自动计算哈希存储在主题中。
这里其实会引入一个新的问题,根据Solidity的官方文档, 对于非值类型的参数(如arrays, bytes, strings), Solidity不会直接存储,而是会将Keccak-256
哈希存储在主题中,从而导致数据信息的丢失。这对于某些依赖于链上事件的DAPP(跨链,用户注册等等)来说,可能会导致事件检索困难,需要解析哈希值。
数据 data
事件中不带 indexed
的参数会被存储在 data
部分中,可以理解为事件的“值”。data
部分的变量不能被直接检索,但可以存储任意大小的数据。因此一般 data
部分可以用来存储复杂的数据结构,例如数组和字符串等等,因为这些数据超过了256比特,即使存储在事件的 topics
部分中,也是以哈希的方式存储。另外,data
部分的变量在存储上消耗的gas相比于 topics
更少。
13 继承
Solidity
中的继承(inheritance
),包括简单继承,多重继承,以及修饰器(Modifier
)和构造函数(Constructor
)的继承。
继承是面向对象编程很重要的组成部分,可以显著减少重复代码。如果把合约看作是对象的话,Solidity
也是面向对象的编程,也支持继承。
规则
virtual
: 父合约中的函数,如果希望子合约重写,需要加上virtual
关键字。override
:子合约重写了父合约中的函数,需要加上override
关键字。
注意:用override
修饰public
变量,会重写与变量同名的getter
函数,例如:
mapping(address => uint256) public override balanceOf;
简单继承
我们先写一个简单的爷爷合约Yeye
,里面包含1个Log
事件和3个function
: hip()
, pop()
, yeye()
,输出都是”Yeye”。
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”
。
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");
}
}
我们部署合约,可以看到Baba
合约里有4个函数,其中hip()
和pop()
的输出被成功改写成”Baba”
,而继承来的yeye()
的输出仍然是”Yeye”
。
多重继承
Solidity
的合约可以继承多个合约。规则:
- 继承时要按辈分最高到最低的顺序排。比如我们写一个
Erzi
合约,继承Yeye
合约和Baba
合约,那么就要写成contract Erzi is Yeye, Baba
,而不能写成contract Erzi is Baba, Yeye
,不然就会报错。 - 如果某一个函数在多个继承的合约里都存在,比如例子中的
hip()
和pop()
,在子合约里必须重写,不然会报错。 - 重写在多个父合约中都重名的函数时,
override
关键字后面要加上所有父合约名字,例如override(Yeye, 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");
}
}
修饰器的继承
Solidity
中的修饰器(Modifier
)同样可以继承,用法与函数继承类似,在相应的地方加virtual
和override
关键字即可。
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
关键字重写修饰器:
modifier exactDividedBy2And3(uint _a) override {
_;
require(_a % 2 == 0 && _a % 3 == 0);
}
构造函数的继承
子合约有两种方法继承父合约的构造函数。举个简单的例子,父合约A
里面有一个状态变量a
,并由构造函数的参数来确定:
// 构造函数的继承
abstract contract A {
uint public a;
constructor(uint _a) {
a = _a;
}
}
- 在继承时声明父构造函数的参数,例如:
contract B is A(1)
- 在子合约的构造函数中声明构造函数的参数,例如:
contract C is A {
constructor(uint _c) A(_c * _c) {}
}
调用父合约的函数
子合约有两种方式调用父合约的函数,直接调用和利用super
关键字。
直接调用:子合约可以直接用父合约名.函数名()
的方式来调用父合约函数,例如Yeye.pop()
function callParent() public{
Yeye.pop();
}
super
关键字:子合约可以利用super.函数名()
来调用最近的父合约函数。Solidity
继承关系按声明时从右到左的顺序是:contract Erzi is Yeye, Baba
,那么Baba
是最近的父合约,super.pop()
将调用Baba.pop()
而不是Yeye.pop()
:
function callParentSuper() public{
// 将调用最近的父合约函数,Baba.pop()
super.pop();
}
钻石继承
在面向对象编程中,钻石继承(菱形继承)指一个派生类同时有两个或两个以上的基类。
在多重+菱形继承链条上使用super
关键字时,需要注意的是使用super
会调用继承链条上的每一个合约的相关函数,而不是只调用最近的父合约。
我们先写一个合约God
,再写Adam
和Eve
两个合约继承God
合约,最后让创建合约people
继承自Adam
和Eve
,每个合约都有foo
和bar
两个函数。
// 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()
会依次调用Eve
、Adam
,最后是God
合约。
虽然Eve
、Adam
都是God
的子合约,但整个过程中God
合约只会被调用一次。原因是Solidity
借鉴了Python的方式,强制一个由基类构成的DAG(有向无环图)使其保证一个特定的顺序。
14 抽象合约和接口
用ERC721
的接口合约为例介绍Solidity
中的抽象合约(abstract
)和接口(interface
)
抽象合约
如果一个智能合约里至少有一个未实现的函数,即某个函数缺少主体{}
中的内容,则必须将该合约标为abstract
,不然编译会报错;另外,未实现的函数需要加virtual
,以便子合约重写。拿我们之前的插入排序合约为例,如果我们还没想好具体怎么实现插入排序函数,那么可以把合约标为abstract
,之后让别人补写上。
abstract contract InsertionSort{
function insertionSort(uint[] memory a) public pure virtual returns(uint[] memory);
}
接口
接口类似于抽象合约,但它不实现任何功能。接口的规则:
- 不能包含状态变量
- 不能包含构造函数
- 不能继承除接口外的其他合约
- 所有函数都必须是external且不能有函数体
- 继承接口的非抽象合约必须实现接口定义的所有功能
虽然接口不实现任何功能,但它非常重要。接口是智能合约的骨架,定义了合约的功能以及如何触发它们:如果智能合约实现了某种接口(比如ERC20
或ERC721
),其他Dapps和智能合约就知道如何与它交互。因为接口提供了两个重要的信息:
- 合约里每个函数的
bytes4
选择器,以及函数签名函数名(每个参数类型)
。 - 接口id(更多信息见EIP165)
另外,接口与合约ABI
(Application Binary Interface)等价,可以相互转换:编译接口可以得到合约的ABI
,利用abi-to-sol工具,也可以将ABI json
文件转换为接口sol
文件。
我们以ERC721
接口合约IERC721
为例,它定义了3个event
和9个function
,所有ERC721
标准的NFT都实现了这些函数。我们可以看到,接口和常规合约的区别在于每个函数都以;
代替函数体{ }
结尾。
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;
}
IERC721事件
IERC721
包含3个事件,其中Transfer
和Approval
事件在ERC20
中也有。
Transfer
事件:在转账时被释放,记录代币的发出地址from
,接收地址to
和tokenId
。Approval
事件:在授权时被释放,记录授权地址owner
,被授权地址approved
和tokenId
。ApprovalForAll
事件:在批量授权时被释放,记录批量授权的发出地址owner
,被授权地址operator
和授权与否的approved
。
IERC721函数
balanceOf
:返回某地址的NFT持有量balance
。ownerOf
:返回某tokenId
的主人owner
。transferFrom
:普通转账,参数为转出地址from
,接收地址to
和tokenId
。safeTransferFrom
:安全转账(如果接收方是合约地址,会要求实现ERC721Receiver
接口)。参数为转出地址from
,接收地址to
和tokenId
。approve
:授权另一个地址使用你的NFT。参数为被授权地址approve
和tokenId
。getApproved
:查询tokenId
被批准给了哪个地址。setApprovalForAll
:将自己持有的该系列NFT批量授权给某个地址operator
。isApprovedForAll
:查询某地址的NFT是否批量授权给了另一个operator
地址。safeTransferFrom
:安全转账的重载函数,参数里面包含了data
。
什么时候使用接口?
如果我们知道一个合约实现了IERC721
接口,我们不需要知道它具体代码实现,就可以与它交互。
无聊猿BAYC
属于ERC721
代币,实现了IERC721
接口的功能。我们不需要知道它的源代码,只需知道它的合约地址,用IERC721
接口就可以与它交互,比如用balanceOf()
来查询某个地址的BAYC
余额,用safeTransferFrom()
来转账BAYC
。
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);
}
}
15 异常
Solidity
三种抛出异常的方法:error
,require
和assert
,并比较三种方法的gas
消耗。
异常
写智能合约经常会出bug
,Solidity
中的异常命令帮助我们debug
。
error
error
是solidity 0.8.4版本
新加的内容,方便且高效(省gas
)地向用户解释操作失败的原因,同时还可以在抛出异常的同时携带参数,帮助开发者更好地调试。人们可以在contract
之外定义异常。下面,我们定义一个TransferNotOwner
异常,当用户不是代币owner
的时候尝试转账,会抛出错误:
error TransferNotOwner(); // 自定义error
我们也可以定义一个携带参数的异常,来提示尝试转账的账户地址
error TransferNotOwner(address sender); // 自定义的带参数的error
在执行当中,error
必须搭配revert
(回退)命令使用。
function transferOwner1(uint256 tokenId, address newOwner) public {
if(_owners[tokenId] != msg.sender){
revert TransferNotOwner();
// revert TransferNotOwner(msg.sender);
}
_owners[tokenId] = newOwner;
}
我们定义了一个transferOwner1()
函数,它会检查代币的owner
是不是发起人,如果不是,就会抛出TransferNotOwner
异常;如果是的话,就会转账。
Require
require
命令是solidity 0.8版本
之前抛出异常的常用方法,目前很多主流合约仍然还在使用它。它很好用,唯一的缺点就是gas
随着描述异常的字符串长度增加,比error
命令要高。使用方法:require(检查条件,"异常的描述")
,当检查条件不成立的时候,就会抛出异常。
我们用require
命令重写一下上面的transferOwner1
函数:
function transferOwner2(uint256 tokenId, address newOwner) public {
require(_owners[tokenId] == msg.sender, "Transfer Not Owner");
_owners[tokenId] = newOwner;
}
Assert
assert
命令一般用于程序员写程序debug
,因为它不能解释抛出异常的原因(比require
少个字符串)。它的用法很简单,assert(检查条件)
,当检查条件不成立的时候,就会抛出异常。
我们用assert
命令重写一下上面的transferOwner1
函数:
function transferOwner3(uint256 tokenId, address newOwner) public {
assert(_owners[tokenId] == msg.sender);
_owners[tokenId] = newOwner;
}
三种方法的gas比较
我们比较一下三种抛出异常的gas
消耗,通过remix控制台的Debug按钮,能查到每次函数调用的gas
消耗分别如下: (使用0.8.17版本编译)
error
方法gas
消耗:24457 (加入参数后gas
消耗:24660)require
方法gas
消耗:24755assert
方法gas
消耗:24473
我们可以看到,error
方法gas
最少,其次是assert
,require
方法消耗gas
最多!因此,error
既可以告知用户抛出异常的原因,又能省gas
,大家要多用!(注意,由于部署测试时间的不同,每个函数的gas
消耗会有所不同,但是比较结果会是一致的。)
备注: Solidity 0.8.0之前的版本,assert
抛出的是一个 panic exception
,会把剩余的 gas
全部消耗,不会返还。更多细节见官方文档。
16 函数重载
Solidity
中允许函数进行重载(overloading
),即名字相同但输入参数类型不同的函数可以同时存在,他们被视为不同的函数。注意,Solidity
不允许修饰器(modifier
)重载。
函数重载
举个例子,我们可以定义两个都叫saySomething()
的函数,一个没有任何参数,输出"Nothing"
;另一个接收一个string
参数,输出这个string
。
function saySomething() public pure returns(string memory){
return("Nothing");
}
function saySomething(string memory something) public pure returns(string memory){
return(something);
}
最终重载函数在经过编译器编译后,由于不同的参数类型,都变成了不同的函数选择器(selector)。
以 Overloading.sol
合约为例,在 Remix 上编译部署后,分别调用重载函数 saySomething()
和 saySomething(string memory something)
,可以看到他们返回了不同的结果,被区分为不同的函数。
实参匹配(Argument Matching)
在调用重载函数时,会把输入的实际参数和函数参数的变量类型做匹配。 如果出现多个匹配的重载函数,则会报错。下面这个例子有两个叫f()
的函数,一个参数为uint8
,另一个为uint256
:
function f(uint8 _in) public pure returns (uint8 out) {
out = _in;
}
function f(uint256 _in) public pure returns (uint256 out) {
out = _in;
}
我们调用f(50)
,因为50
既可以被转换为uint8
,也可以被转换为uint256
,因此会报错。
17 库合约
用ERC721
的引用的库合约Strings
为例介绍Solidity
中的库合约(Library
),并总结了常用的库合约。
库合约
库合约是一种特殊的合约,为了提升Solidity
代码的复用性和减少gas
而存在。库合约是一系列的函数合集,
他和普通合约主要有以下几点不同:
- 不能存在状态变量
- 不能够继承或被继承
- 不能接收以太币
- 不可以被销毁
需要注意的是,库合约中的函数可见性如果被设置为public
或者external
,则在调用函数时会触发一次delegatecall
。而如果被设置为internal
,则不会引起。对于设置为private
可见性的函数来说,其仅能在库合约中可见,在其他合约中不可用。
Strings库合约
Strings库合约
是将uint256
类型转换为相应的string
类型的代码库,样例代码如下:
library Strings {
bytes16 private constant _HEX_SYMBOLS = "0123456789abcdef";
/**
* @dev Converts a `uint256` to its ASCII `string` decimal representation.
*/
function toString(uint256 value) public pure returns (string memory) {
// Inspired by OraclizeAPI's implementation - MIT licence
// https://github.com/oraclize/ethereum-api/blob/b42146b063c7d6ee1358846c198246239e9360e8/oraclizeAPI_0.4.25.sol
if (value == 0) {
return "0";
}
uint256 temp = value;
uint256 digits;
while (temp != 0) {
digits++;
temp /= 10;
}
bytes memory buffer = new bytes(digits);
while (value != 0) {
digits -= 1;
buffer[digits] = bytes1(uint8(48 + uint256(value % 10)));
value /= 10;
}
return string(buffer);
}
/**
* @dev Converts a `uint256` to its ASCII `string` hexadecimal representation.
*/
function toHexString(uint256 value) public pure returns (string memory) {
if (value == 0) {
return "0x00";
}
uint256 temp = value;
uint256 length = 0;
while (temp != 0) {
length++;
temp >>= 8;
}
return toHexString(value, length);
}
/**
* @dev Converts a `uint256` to its ASCII `string` hexadecimal representation with fixed length.
*/
function toHexString(uint256 value, uint256 length) public pure returns (string memory) {
bytes memory buffer = new bytes(2 * length + 2);
buffer[0] = "0";
buffer[1] = "x";
for (uint256 i = 2 * length + 1; i > 1; --i) {
buffer[i] = _HEX_SYMBOLS[value & 0xf];
value >>= 4;
}
require(value == 0, "Strings: hex length insufficient");
return string(buffer);
}
}
它主要包含两个函数,toString()
将uint256
转换为10进制的string
,toHexString()
将uint256
转换为16进制的string
。
如何使用库合约
我们用Strings
库合约的toHexString()
来演示两种使用库合约中函数的办法。
利用using for指令指令using A for B;
可用于附加库合约(从库 A)到任何类型(B)。添加完指令后,库A
中的函数会自动添加为B
类型变量的成员,可以直接调用。注意:在调用的时候,这个变量会被当作第一个参数传递给函数:
// 利用using for指令
using Strings for uint256;
function getString1(uint256 _number) public pure returns(string memory){
// 库合约中的函数会自动添加为uint256型变量的成员
return _number.toHexString();
}
通过库合约名称调用函数
// 直接通过库合约名调用
function getString2(uint256 _number) public pure returns(string memory){
return Strings.toHexString(_number);
}
我们部署合约并输入170
测试一下,两种方法均能返回正确的16进制string
“0xaa”。证明我们调用库合约成功!
常用合约
18 Import
在Solidity中,import
语句可以帮助我们在一个文件中引用另一个文件的内容,提高代码的可重用性和组织性。
import用法
通过源文件相对位置导入,例子:
文件结构
├── Import.sol
└── Yeye.sol
// 通过文件相对位置import
import './Yeye.sol';
通过源文件网址导入网上的合约的全局符号,例子:
// 通过网址引用
import 'https://github.com/OpenZeppelin/openzeppelin-contracts/blob/master/contracts/utils/Address.sol';
通过npm
的目录导入,例子:
import '@openzeppelin/contracts/access/Ownable.sol';
通过指定全局符号
导入合约特定的全局符号,例子:
import {Yeye} from './Yeye.sol';
引用(import
)在代码中的位置为:在声明版本号之后,在其余代码之前。
测试导入结果
我们可以用下面这段代码测试是否成功导入了外部源代码:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.21;
// 通过文件相对位置import
import './Yeye.sol';
// 通过`全局符号`导入特定的合约
import {Yeye} from './Yeye.sol';
// 通过网址引用
import 'https://github.com/OpenZeppelin/openzeppelin-contracts/blob/master/contracts/utils/Address.sol';
// 引用OpenZeppelin合约
import '@openzeppelin/contracts/access/Ownable.sol';
contract Import {
// 成功导入Address库
using Address for address;
// 声明yeye变量
Yeye yeye = new Yeye();
// 测试是否能调用yeye的函数
function test() external{
yeye.hip();
}
}
19 接收ETH
Solidity
支持两种特殊的回调函数,receive()
和fallback()
,他们主要在两种情况下被使用:
- 接收ETH
- 处理合约中不存在的函数调用(代理合约proxy contract)
注意⚠️:在Solidity 0.6.x版本之前,语法上只有 fallback()
函数,用来接收用户发送的ETH时调用以及在被调用函数签名没有匹配到时,来调用。 0.6版本之后,Solidity才将 fallback()
函数拆分成 receive()
和 fallback()
两个函数。
我们这一讲主要讲接收ETH的情况。
接收ETH函数 receive
receive()
函数是在合约收到ETH
转账时被调用的函数。一个合约最多有一个receive()
函数,声明方式与一般函数不一样,不需要function
关键字:receive() external payable { ... }
。receive()
函数不能有任何的参数,不能返回任何值,必须包含external
和payable
。
当合约接收ETH的时候,receive()
会被触发。receive()
最好不要执行太多的逻辑因为如果别人用send
和transfer
方法发送ETH
的话,gas
会限制在2300
,receive()
太复杂可能会触发Out of Gas
报错;如果用call
就可以自定义gas
执行更复杂的逻辑(这三种发送ETH的方法我们之后会讲到)。
我们可以在receive()
里发送一个event
,例如:
// 定义事件
event Received(address Sender, uint Value);
// 接收ETH时释放Received事件
receive() external payable {
emit Received(msg.sender, msg.value);
}
有些恶意合约,会在receive()
函数(老版本的话,就是 fallback()
函数)嵌入恶意消耗gas
的内容或者使得执行故意失败的代码,导致一些包含退款和转账逻辑的合约不能正常工作,因此写包含退款等逻辑的合约时候,一定要注意这种情况。
回退函数 fallback
fallback()
函数会在调用合约不存在的函数时被触发。可用于接收ETH,也可以用于代理合约proxy contract
。fallback()
声明时不需要function
关键字,必须由external
修饰,一般也会用payable
修饰,用于接收ETH:fallback() external payable { ... }
。
我们定义一个fallback()
函数,被触发时候会释放fallbackCalled
事件,并输出msg.sender
,msg.value
和msg.data
:
event fallbackCalled(address Sender, uint Value, bytes Data);
// fallback
fallback() external payable{
emit fallbackCalled(msg.sender, msg.value, msg.data);
}
receive和fallback的区别
receive
和fallback
都能够用于接收ETH
,他们触发的规则如下:
触发fallback() 还是 receive()?
接收ETH
|
msg.data是空?
/ \
是 否
/ \
receive()存在? fallback()
/ \
是 否
/ \
receive() fallback()
简单来说,合约接收ETH
时,msg.data
为空且存在receive()
时,会触发receive()
;msg.data
不为空或不存在receive()
时,会触发fallback()
,此时fallback()
必须为payable
。
receive()
和payable fallback()
均不存在的时候,向合约直接发送ETH
将会报错(你仍可以通过带有payable
的函数向合约发送ETH
)。
20 发送ETH
Solidity
有三种方法向其他合约发送ETH
,他们是:transfer()
,send()
和call()
,其中call()
是被鼓励的用法。
接收ETH合约
我们先部署一个接收ETH
合约ReceiveETH
。ReceiveETH
合约里有一个事件Log
,记录收到的ETH
数量和gas
剩余。还有两个函数,一个是receive()
函数,收到ETH
被触发,并发送Log
事件;另一个是查询合约ETH
余额的getBalance()
函数。
contract ReceiveETH {
// 收到eth事件,记录amount和gas
event Log(uint amount, uint gas);
// receive方法,接收eth时被触发
receive() external payable{
emit Log(msg.value, gasleft());
}
// 返回合约ETH余额
function getBalance() view public returns(uint) {
return address(this).balance;
}
}
部署ReceiveETH
合约后,运行getBalance()
函数,可以看到当前合约的ETH
余额为0
。
发送ETH合约
我们将实现三种方法向ReceiveETH
合约发送ETH
。首先,先在发送ETH合约SendETH
中实现payable
的构造函数
和receive()
,让我们能够在部署时和部署后向合约转账。
contract SendETH {
// 构造函数,payable使得部署的时候可以转eth进去
constructor() payable{}
// receive方法,接收eth时被触发
receive() external payable{}
}
transfer
- 用法是
接收方地址.transfer(发送ETH数额)
。 transfer()
的gas
限制是2300
,足够用于转账,但对方合约的fallback()
或receive()
函数不能实现太复杂的逻辑。transfer()
如果转账失败,会自动revert
(回滚交易)。
代码样例,注意里面的_to
填ReceiveETH
合约的地址,amount
是ETH
转账金额:
// 用transfer()发送ETH
function transferETH(address payable _to, uint256 amount) external payable{
_to.transfer(amount);
}
部署SendETH
合约后,对ReceiveETH
合约发送ETH,此时amount
为10,value
为0,amount
>value
,转账失败,发生revert
。
此时amount
为10,value
为10,amount
<=value
,转账成功。
在ReceiveETH
合约中,运行getBalance()
函数,可以看到当前合约的ETH
余额为10
。
send
- 用法是
接收方地址.send(发送ETH数额)
。 send()
的gas
限制是2300
,足够用于转账,但对方合约的fallback()
或receive()
函数不能实现太复杂的逻辑。send()
如果转账失败,不会revert
。send()
的返回值是bool
,代表着转账成功或失败,需要额外代码处理一下。
代码样例:
error SendFailed(); // 用send发送ETH失败error
// send()发送ETH
function sendETH(address payable _to, uint256 amount) external payable{
// 处理下send的返回值,如果失败,revert交易并发送error
bool success = _to.send(amount);
if(!success){
revert SendFailed();
}
}
对ReceiveETH
合约发送ETH,此时amount
为10,value
为0,amount
>value
,转账失败,因为经过处理,所以发生revert
。
此时amount
为10,value
为11,amount
<=value
,转账成功。
call
- 用法是
接收方地址.call{value: 发送ETH数额}("")
。 call()
没有gas
限制,可以支持对方合约fallback()
或receive()
函数实现复杂逻辑。call()
如果转账失败,不会revert
。call()
的返回值是(bool, bytes)
,其中bool
代表着转账成功或失败,需要额外代码处理一下。
代码样例:
error CallFailed(); // 用call发送ETH失败error
// call()发送ETH
function callETH(address payable _to, uint256 amount) external payable{
// 处理下call的返回值,如果失败,revert交易并发送error
(bool success,) = _to.call{value: amount}("");
if(!success){
revert CallFailed();
}
}
对ReceiveETH
合约发送ETH,此时amount
为10,value
为0,amount
>value
,转账失败,因为经过处理,所以发生revert
。
此时amount
为10,value
为11,amount
<=value
,转账成功。
运行三种方法,可以看到,他们都可以成功地向
ReceiveETH
合约发送ETH
。
call
没有gas
限制,最为灵活,是最提倡的方法;transfer
有2300 gas
限制,但是发送失败会自动revert
交易,是次优选择;send
有2300 gas
限制,而且发送失败不会自动revert
交易,几乎没有人用它。
21 调用其他合约
一个合约可以调用另一个合约的函数,这在构建复杂的DApps时非常有用。本教程将会介绍如何在已知合约代码(或接口)和地址的情况下,调用已部署的合约。
目标合约
我们先写一个简单的合约OtherContract
,用于被其他合约调用。
contract OtherContract {
uint256 private _x = 0; // 状态变量_x
// 收到eth的事件,记录amount和gas
event Log(uint amount, uint gas);
// 返回合约ETH余额
function getBalance() view public returns(uint) {
return address(this).balance;
}
// 可以调整状态变量_x的函数,并且可以往合约转ETH (payable)
function setX(uint256 x) external payable{
_x = x;
// 如果转入ETH,则释放Log事件
if(msg.value > 0){
emit Log(msg.value, gasleft());
}
}
// 读取_x
function getX() external view returns(uint x){
x = _x;
}
}
这个合约包含一个状态变量_x
,一个事件Log
在收到ETH
时触发,三个函数:
getBalance()
: 返回合约ETH
余额。setX()
:external payable
函数,可以设置_x
的值,并向合约发送ETH
。getX()
: 读取_x
的值。
调用OtherContract
合约
我们可以利用合约的地址和合约代码(或接口)来创建合约的引用:_Name(_Address)
,其中_Name
是合约名,应与合约代码(或接口)中标注的合约名保持一致,_Address
是合约地址。然后用合约的引用来调用它的函数:_Name(_Address).f()
,其中f()
是要调用的函数。
下面我们介绍4个调用合约的例子,在remix中编译合约后,分别部署OtherContract
和CallContract
:
1. 传入合约地址
我们可以在函数里传入目标合约地址,生成目标合约的引用,然后调用目标函数。以调用OtherContract
合约的setX
函数为例,我们在新合约中写一个callSetX
函数,传入已部署好的OtherContract
合约地址_Address
和setX
的参数x
:
function callSetX(address _Address, uint256 x) external{
OtherContract(_Address).setX(x);
}
复制OtherContract
合约的地址,填入callSetX
函数的参数中,成功调用后,调用OtherContract
合约中的getX
验证x
变为123
2. 传入合约变量
我们可以直接在函数里传入合约的引用,只需要把上面参数的address
类型改为目标合约名,比如OtherContract
。下面例子实现了调用目标合约的getX()
函数。
注意:该函数参数OtherContract _Address
底层类型仍然是address
,生成的ABI
中、调用callGetX
时传入的参数都是address
类型
function callGetX(OtherContract _Address) external view returns(uint x){
x = _Address.getX();
}
复制OtherContract
合约的地址,填入callGetX
函数的参数中,调用后成功获取x
的值
3. 创建合约变量
我们可以创建合约变量,然后通过它来调用目标函数。下面例子,我们给变量oc
存储了OtherContract
合约的引用:
function callGetX2(address _Address) external view returns(uint x){
OtherContract oc = OtherContract(_Address);
x = oc.getX();
}
复制OtherContract
合约的地址,填入callGetX2
函数的参数中,调用后成功获取x
的值
4. 调用合约并发送ETH
如果目标合约的函数是payable
的,那么我们可以通过调用它来给合约转账:_Name(_Address).f{value: _Value}()
,其中_Name
是合约名,_Address
是合约地址,f
是目标函数名,_Value
是要转的ETH
数额(以wei
为单位)。
OtherContract
合约的setX
函数是payable
的,在下面这个例子中我们通过调用setX
来往目标合约转账。
function setXTransferETH(address otherContract, uint256 x) payable external{
OtherContract(otherContract).setX{value: msg.value}(x);
}
复制OtherContract
合约的地址,填入setXTransferETH
函数的参数中,并转入10ETH
转账后,我们可以通过Log
事件和getBalance()
函数观察目标合约ETH
余额的变化。
22 Call
Call
call
是address
类型的低级成员函数,它用来与其他合约交互。它的返回值为(bool, bytes memory)
,分别对应call
是否成功以及目标函数的返回值。
call
是Solidity
官方推荐的通过触发fallback
或receive
函数发送ETH
的方法。- 不推荐用
call
来调用另一个合约,因为当你调用不安全合约的函数时,你就把主动权交给了它。推荐的方法仍是声明合约变量后调用函数,见第21讲:调用其他合约 - 当我们不知道对方合约的源代码或
ABI
,就没法生成合约变量;这时,我们仍可以通过call
调用对方合约的函数。
call
的使用规则
call
的使用规则如下:
目标合约地址.call(字节码);
其中字节码
利用结构化编码函数abi.encodeWithSignature
获得:
abi.encodeWithSignature("函数签名", 逗号分隔的具体参数)
函数签名
为"函数名(逗号分隔的参数类型)"
。例如abi.encodeWithSignature("f(uint256,address)", _x, _addr)
。
另外call
在调用合约时可以指定交易发送的ETH
数额和gas
数额:
目标合约地址.call{value:发送数额, gas:gas数额}(字节码);
看起来有点复杂,下面我们举个call
应用的例子。
目标合约
我们先写一个简单的目标合约OtherContract
并部署,代码与第21讲中基本相同,只是多了fallback
函数。
contract OtherContract {
uint256 private _x = 0; // 状态变量x
// 收到eth的事件,记录amount和gas
event Log(uint amount, uint gas);
fallback() external payable{}
// 返回合约ETH余额
function getBalance() view public returns(uint) {
return address(this).balance;
}
// 可以调整状态变量_x的函数,并且可以往合约转ETH (payable)
function setX(uint256 x) external payable{
_x = x;
// 如果转入ETH,则释放Log事件
if(msg.value > 0){
emit Log(msg.value, gasleft());
}
}
// 读取x
function getX() external view returns(uint x){
x = _x;
}
}
这个合约包含一个状态变量x
,一个在收到ETH
时触发的事件Log
,三个函数:
getBalance()
: 返回合约ETH
余额。setX()
:external payable
函数,可以设置x
的值,并向合约发送ETH
。getX()
: 读取x
的值。
利用call
调用目标合约
1. Response事件
我们写一个Call
合约来调用目标合约函数。首先定义一个Response
事件,输出call
返回的success
和data
,方便我们观察返回值。
// 定义Response事件,输出call返回的结果success和data
event Response(bool success, bytes data);
2. 调用setX函数
我们定义callSetX
函数来调用目标合约的setX()
,转入msg.value
数额的ETH
,并释放Response
事件输出success
和data
:
function callSetX(address payable _addr, uint256 x) public payable {
// call setX(),同时可以发送ETH
(bool success, bytes memory data) = _addr.call{value: msg.value}(
abi.encodeWithSignature("setX(uint256)", x)
);
emit Response(success, data); //释放事件
}
接下来我们调用callSetX
把状态变量_x
改为5,参数为OtherContract
地址和5
,由于目标函数setX()
没有返回值,因此Response
事件输出的data
为0x
,也就是空。
3. 调用getX函数
下面我们调用getX()
函数,它将返回目标合约_x
的值,类型为uint256
。我们可以利用abi.decode
来解码call
的返回值data
,并读出数值。
function callGetX(address _addr) external returns(uint256){
// call getX()
(bool success, bytes memory data) = _addr.call(
abi.encodeWithSignature("getX()")
);
emit Response(success, data); //释放事件
return abi.decode(data, (uint256));
}
从Response
事件的输出,我们可以看到data
为0x0000000000000000000000000000000000000000000000000000000000000005
。而经过abi.decode
,最终返回值为5
。
4. 调用不存在的函数
如果我们给call
输入的函数不存在于目标合约,那么目标合约的fallback
函数会被触发。
function callNonExist(address _addr) external{
// call 不存在的函数
(bool success, bytes memory data) = _addr.call(
abi.encodeWithSignature("foo(uint256)")
);
emit Response(success, data); //释放事件
}
上面例子中,我们call
了不存在的foo
函数。call
仍能执行成功,并返回success
,但其实调用的目标合约fallback
函数。
23 Delegatecall
delegatecall
与call
类似,是Solidity
中地址类型的低级成员函数。delegate
中是委托/代表的意思,那么delegatecall
委托了什么?
当用户A
通过合约B
来call
合约C
的时候,执行的是合约C
的函数,上下文
(Context
,可以理解为包含变量和状态的环境)也是合约C
的:msg.sender
是B
的地址,并且如果函数改变一些状态变量,产生的效果会作用于合约C
的变量上。
而当用户A
通过合约B
来delegatecall
合约C
的时候,执行的是合约C
的函数,但是上下文
仍是合约B
的:msg.sender
是A
的地址,并且如果函数改变一些状态变量,产生的效果会作用于合约B
的变量上。
大家可以这样理解:一个投资者(用户A
)把他的资产(B
合约的状态变量
)都交给一个风险投资代理(C
合约)来打理。执行的是风险投资代理的函数,但是改变的是资产的状态。
delegatecall
语法和call
类似,也是:
目标合约地址.delegatecall(二进制编码);
其中二进制编码
利用结构化编码函数abi.encodeWithSignature
获得:
abi.encodeWithSignature("函数签名", 逗号分隔的具体参数)
函数签名
为"函数名(逗号分隔的参数类型)"
。例如abi.encodeWithSignature("f(uint256,address)", _x, _addr)
。
和call
不一样,delegatecall
在调用合约时可以指定交易发送的gas
,但不能指定发送的ETH
数额
注意:
delegatecall
有安全隐患,使用时要保证当前合约和目标合约的状态变量存储结构相同,并且目标合约安全,不然会造成资产损失。
什么情况下会用到delegatecall
?
目前delegatecall
主要有两个应用场景:
- 代理合约(
Proxy Contract
):将智能合约的存储合约和逻辑合约分开:代理合约(Proxy Contract
)存储所有相关的变量,并且保存逻辑合约的地址;所有函数存在逻辑合约(Logic Contract
)里,通过delegatecall
执行。当升级时,只需要将代理合约指向新的逻辑合约即可。 - EIP-2535 Diamonds(钻石):钻石是一个支持构建可在生产中扩展的模块化智能合约系统的标准。钻石是具有多个实施合约的代理合约。
delegatecall
例子
调用结构:你(A
)通过合约B
调用目标合约C
。
被调用的合约C
我们先写一个简单的目标合约C
:有两个public
变量:num
和sender
,分别是uint256
和address
类型;有一个函数,可以将num
设定为传入的_num
,并且将sender
设为msg.sender
。
// 被调用的合约C
contract C {
uint public num;
address public sender;
function setVars(uint _num) public payable {
num = _num;
sender = msg.sender;
}
}
发起调用的合约B
首先,合约B
必须和目标合约C
的变量存储布局必须相同 —— 即存在两个 public
变量且变量类型顺序为 uint256
和 address
注意: 变量名称可以不同
contract B {
uint public num;
address public sender;
}
接下来,我们分别用call
和delegatecall
来调用合约C
的setVars
函数,更好的理解它们的区别。
callSetVars
函数通过call
来调用setVars
。它有两个参数_addr
和_num
,分别对应合约C
的地址和setVars
的参数。
// 通过call来调用C的setVars()函数,将改变合约C里的状态变量
function callSetVars(address _addr, uint _num) external payable{
// call setVars()
(bool success, bytes memory data) = _addr.call(
abi.encodeWithSignature("setVars(uint256)", _num)
);
}
而delegatecallSetVars
函数通过delegatecall
来调用setVars
。与上面的callSetVars
函数相同,有两个参数_addr
和_num
,分别对应合约C
的地址和setVars
的参数。
// 通过delegatecall来调用C的setVars()函数,将改变合约B里的状态变量
function delegatecallSetVars(address _addr, uint _num) external payable{
// delegatecall setVars()
(bool success, bytes memory data) = _addr.delegatecall(
abi.encodeWithSignature("setVars(uint256)", _num)
);
}
24 在合约中创建新合约
在以太坊链上,用户(外部账户,EOA
)可以创建智能合约,智能合约同样也可以创建新的智能合约。去中心化交易所uniswap
就是利用工厂合约(PairFactory
)创建了无数个币对合约(Pair
)。这一讲,我会用简化版的uniswap
讲如何通过合约创建合约。
create
有两种方法可以在合约中创建新合约,create
和create2
,这里我们讲create
,下一讲会介绍create2
。
create
的用法很简单,就是new
一个合约,并传入新合约构造函数所需的参数:
Contract x = new Contract{value: _value}(params)
其中Contract
是要创建的合约名,x
是合约对象(地址),如果构造函数是payable
,可以创建时转入_value
数量的ETH
,params
是新合约构造函数的参数。
极简Uniswap
Uniswap V2
核心合约中包含两个合约:
- UniswapV2Pair: 币对合约,用于管理币对地址、流动性、买卖。
- UniswapV2Factory: 工厂合约,用于创建新的币对,并管理币对地址。
下面我们用create
方法实现一个极简版的Uniswap
:Pair
币对合约负责管理币对地址,PairFactory
工厂合约用于创建新的币对,并管理币对地址。
Pair
合约
contract Pair{
address public factory; // 工厂合约地址
address public token0; // 代币1
address public token1; // 代币2
constructor() payable {
factory = msg.sender;
}
// called once by the factory at time of deployment
function initialize(address _token0, address _token1) external {
require(msg.sender == factory, 'UniswapV2: FORBIDDEN'); // sufficient check
token0 = _token0;
token1 = _token1;
}
}
Pair
合约很简单,包含3个状态变量:factory
,token0
和token1
。
构造函数constructor
在部署时将factory
赋值为工厂合约地址。initialize
函数会由工厂合约在部署完成后手动调用以初始化代币地址,将token0
和token1
更新为币对中两种代币的地址。
提问:为什么
uniswap
不在constructor
中将token0
和token1
地址更新好?答:因为
uniswap
使用的是create2
创建合约,生成的合约地址可以实现预测,更多详情请阅读第25讲。
PairFactory
contract PairFactory{
mapping(address => mapping(address => address)) public getPair; // 通过两个代币地址查Pair地址
address[] public allPairs; // 保存所有Pair地址
function createPair(address tokenA, address tokenB) external returns (address pairAddr) {
// 创建新合约
Pair pair = new Pair();
// 调用新合约的initialize方法
pair.initialize(tokenA, tokenB);
// 更新地址map
pairAddr = address(pair);
allPairs.push(pairAddr);
getPair[tokenA][tokenB] = pairAddr;
getPair[tokenB][tokenA] = pairAddr;
}
}
工厂合约(PairFactory
)有两个状态变量getPair
是两个代币地址到币对地址的map
,方便根据代币找到币对地址;allPairs
是币对地址的数组,存储了所有币对地址。
PairFactory
合约只有一个createPair
函数,根据输入的两个代币地址tokenA
和tokenB
来创建新的Pair
合约。其中
Pair pair = new Pair();
就是创建合约的代码,非常简单。大家可以部署好PairFactory
合约,然后用下面两个地址作为参数调用createPair
,看看创建的币对地址是什么:
WBNB地址: 0x2c44b726ADF1963cA47Af88B284C06f30380fC78
BSC链上的PEOPLE地址: 0xbb4CdB9CBd36B01bD1cBaEBF2De08d9173bc095c
25 Create2
CREATE2
操作码使我们在智能合约部署在以太坊网络之前就能预测合约的地址。Uniswap
创建Pair
合约用的就是CREATE2
而不是CREATE
。这一讲,我将介绍CREATE2
的用法
CREATE如何计算地址
智能合约可以由其他合约和普通账户利用CREATE
操作码创建。 在这两种情况下,新合约的地址都以相同的方式计算:创建者的地址(通常为部署的钱包地址或者合约地址)和nonce
(该地址发送交易的总数,对于合约账户是创建的合约总数,每创建一个合约nonce+1)的哈希。
新地址 = hash(创建者地址, nonce)
创建者地址不会变,但nonce
可能会随时间而改变,因此用CREATE
创建的合约地址不好预测。
CREATE2如何计算地址
CREATE2
的目的是为了让合约地址独立于未来的事件。不管未来区块链上发生了什么,你都可以把合约部署在事先计算好的地址上。用CREATE2
创建的合约地址由4个部分决定:
0xFF
:一个常数,避免和CREATE
冲突CreatorAddress
: 调用 CREATE2 的当前合约(创建合约)地址。salt
(盐):一个创建者指定的bytes32
类型的值,它的主要目的是用来影响新创建的合约的地址。initcode
: 新合约的初始字节码(合约的Creation Code和构造函数的参数)。
新地址 = hash("0xFF",创建者地址, salt, initcode)
CREATE2
确保,如果创建者使用 CREATE2
和提供的 salt
部署给定的合约initcode
,它将存储在 新地址
中。
如何使用CREATE2
CREATE2
的用法和之前讲的CREATE
类似,同样是new
一个合约,并传入新合约构造函数所需的参数,只不过要多传一个salt
参数:
Contract x = new Contract{salt: _salt, value: _value}(params)
其中Contract
是要创建的合约名,x
是合约对象(地址),_salt
是指定的盐;如果构造函数是payable
,可以创建时转入_value
数量的ETH
,params
是新合约构造函数的参数。
极简Uniswap2
跟上一讲类似,我们用CREATE2
来实现极简Uniswap
。
Pair
contract Pair{
address public factory; // 工厂合约地址
address public token0; // 代币1
address public token1; // 代币2
constructor() payable {
factory = msg.sender;
}
// called once by the factory at time of deployment
function initialize(address _token0, address _token1) external {
require(msg.sender == factory, 'UniswapV2: FORBIDDEN'); // sufficient check
token0 = _token0;
token1 = _token1;
}
}
Pair
合约很简单,包含3个状态变量:factory
,token0
和token1
。
构造函数constructor
在部署时将factory
赋值为工厂合约地址。initialize
函数会在Pair
合约创建的时候被工厂合约调用一次,将token0
和token1
更新为币对中两种代币的地址。
PairFactory2
contract PairFactory2{
mapping(address => mapping(address => address)) public getPair; // 通过两个代币地址查Pair地址
address[] public allPairs; // 保存所有Pair地址
function createPair2(address tokenA, address tokenB) external returns (address pairAddr) {
require(tokenA != tokenB, 'IDENTICAL_ADDRESSES'); //避免tokenA和tokenB相同产生的冲突
// 用tokenA和tokenB地址计算salt
(address token0, address token1) = tokenA < tokenB ? (tokenA, tokenB) : (tokenB, tokenA); //将tokenA和tokenB按大小排序
bytes32 salt = keccak256(abi.encodePacked(token0, token1));
// 用create2部署新合约
Pair pair = new Pair{salt: salt}();
// 调用新合约的initialize方法
pair.initialize(tokenA, tokenB);
// 更新地址map
pairAddr = address(pair);
allPairs.push(pairAddr);
getPair[tokenA][tokenB] = pairAddr;
getPair[tokenB][tokenA] = pairAddr;
}
}
工厂合约(PairFactory2
)有两个状态变量getPair
是两个代币地址到币对地址的map
,方便根据代币找到币对地址;allPairs
是币对地址的数组,存储了所有币对地址。
PairFactory2
合约只有一个createPair2
函数,使用CREATE2
根据输入的两个代币地址tokenA
和tokenB
来创建新的Pair
合约。其中
Pair pair = new Pair{salt: salt}();
就是利用CREATE2
创建合约的代码,非常简单,而salt
为token1
和token2
的hash
:
bytes32 salt = keccak256(abi.encodePacked(token0, token1));
事先计算Pair
地址
// 提前计算pair合约地址
function calculateAddr(address tokenA, address tokenB) public view returns(address predictedAddress){
require(tokenA != tokenB, 'IDENTICAL_ADDRESSES'); //避免tokenA和tokenB相同产生的冲突
// 计算用tokenA和tokenB地址计算salt
(address token0, address token1) = tokenA < tokenB ? (tokenA, tokenB) : (tokenB, tokenA); //将tokenA和tokenB按大小排序
bytes32 salt = keccak256(abi.encodePacked(token0, token1));
// 计算合约地址方法 hash()
predictedAddress = address(uint160(uint(keccak256(abi.encodePacked(
bytes1(0xff),
address(this),
salt,
keccak256(type(Pair).creationCode)
)))));
}
我们写了一个calculateAddr
函数来事先计算tokenA
和tokenB
将会生成的Pair
地址。通过它,我们可以验证我们事先计算的地址和实际地址是否相同。
大家可以部署好PairFactory2
合约,然后用下面两个地址作为参数调用createPair2
,看看创建的币对地址是什么,是否与事先计算的地址一样:
WBNB地址: 0x2c44b726ADF1963cA47Af88B284C06f30380fC78
BSC链上的PEOPLE地址: 0xbb4CdB9CBd36B01bD1cBaEBF2De08d9173bc095c
如果部署合约构造函数中存在参数
例如当create2合约时:
Pair pair = new Pair{salt: salt}(address(this));
计算时,需要将参数和initcode一起进行打包:
keccak256(type(Pair).creationCode) => keccak256(abi.encodePacked(type(Pair).creationCode, abi.encode(address(this))))
predictedAddress = address(uint160(uint(keccak256(abi.encodePacked(
bytes1(0xff),
address(this),
salt,
keccak256(abi.encodePacked(type(Pair).creationCode, abi.encode(address(this))))
)))));
26 删除合约
selfdestruct
命令可以用来删除智能合约,并将该合约剩余ETH
转到指定地址。selfdestruct
是为了应对合约出错的极端情况而设计的。它最早被命名为suicide
(自杀),但是这个词太敏感。为了保护抑郁的程序员,改名为selfdestruct
;在 v0.8.18 版本中,selfdestruct
关键字被标记为「不再建议使用」,在一些情况下它会导致预期之外的合约语义,但由于目前还没有代替方案,目前只是对开发者做了编译阶段的警告,相关内容可以查看 EIP-6049。
然而,在以太坊坎昆(Cancun)升级中,EIP-6780被纳入升级以实现对Verkle Tree
更好的支持。EIP-6780减少了SELFDESTRUCT
操作码的功能。根据提案描述,当前SELFDESTRUCT
仅会被用来将合约中的ETH转移到指定地址,而原先的删除功能只有在合约创建-自毁
这两个操作处在同一笔交易时才能生效。所以目前来说:
- 已经部署的合约无法被
SELFDESTRUCT
了。 - 如果要使用原先的
SELFDESTRUCT
功能,必须在同一笔交易中创建并SELFDESTRUCT
。
如何使用selfdestruct
selfdestruct
使用起来非常简单:
selfdestruct(_addr);
其中_addr
是接收合约中剩余ETH
的地址。_addr
地址不需要有receive()
或fallback()
也能接收ETH
。
Demo-转移ETH功能
以下合约在坎昆升级前可以完成合约的自毁,在坎昆升级后仅能实现内部ETH余额的转移。
contract DeleteContract {
uint public value = 10;
constructor() payable {}
receive() external payable {}
function deleteContract() external {
// 调用selfdestruct销毁合约,并把剩余的ETH转给msg.sender
selfdestruct(payable(msg.sender));
}
function getBalance() external view returns(uint balance){
balance = address(this).balance;
}
}
在DeleteContract
合约中,我们写了一个public
状态变量value
,两个函数:getBalance()
用于获取合约ETH
余额,deleteContract()
用于自毁合约,并把ETH
转入给发起人。
部署好合约后,我们向DeleteContract
合约转入1 ETH
。这时,getBalance()
会返回1 ETH
,value
变量是10。
当我们调用deleteContract()
函数,合约将触发selfdestruct
操作。在坎昆升级前,合约会被自毁。但是在升级后,合约依然存在,只是将合约包含的ETH转移到指定地址,而合约依然能够调用。
注意事项
- 对外提供合约销毁接口时,最好设置为只有合约所有者可以调用,可以使用函数修饰符
onlyOwner
进行函数声明。 - 当合约中有
selfdestruct
功能时常常会带来安全问题和信任问题,合约中的selfdestruct功能会为攻击者打开攻击向量(例如使用selfdestruct
向一个合约频繁转入token进行攻击,这将大大节省了GAS的费用,虽然很少人这么做),此外,此功能还会降低用户对合约的信心。
27 ABI编码解码
ABI
(Application Binary Interface,应用二进制接口)是与以太坊智能合约交互的标准。数据基于他们的类型编码;并且由于编码后不包含类型信息,解码时需要注明它们的类型。
Solidity
中,ABI编码
有4个函数:abi.encode
, abi.encodePacked
, abi.encodeWithSignature
, abi.encodeWithSelector
。而ABI解码
有1个函数:abi.decode
,用于解码abi.encode
的数据。这一讲,我们将学习如何使用这些函数。
ABI编码
我们将编码4个变量,他们的类型分别是uint256
(别名 uint), address
, string
, uint256[2]
:
uint x = 10;
address addr = 0x7A58c0Be72BE218B41C608b7Fe7C5bB630736C71;
string name = "0xAA";
uint[2] array = [5, 6];
abi.encode
将给定参数利用ABI规则编码。ABI
被设计出来跟智能合约交互,他将每个参数填充为32字节的数据,并拼接在一起。如果你要和合约交互,你要用的就是abi.encode
。
function encode() public view returns(bytes memory result) {
result = abi.encode(x, addr, name, array);
}
编码的结果为0x000000000000000000000000000000000000000000000000000000000000000a0000000000000000000000007a58c0be72be218b41c608b7fe7c5bb630736c7100000000000000000000000000000000000000000000000000000000000000a00000000000000000000000000000000000000000000000000000000000000005000000000000000000000000000000000000000000000000000000000000000600000000000000000000000000000000000000000000000000000000000000043078414100000000000000000000000000000000000000000000000000000000
,详细解释下编码的细节:
000000000000000000000000000000000000000000000000000000000000000a // x
0000000000000000000000007a58c0be72be218b41c608b7fe7c5bb630736c71 // addr
00000000000000000000000000000000000000000000000000000000000000a0 // name 参数的偏移量
0000000000000000000000000000000000000000000000000000000000000005 // array[0]
0000000000000000000000000000000000000000000000000000000000000006 // array[1]
0000000000000000000000000000000000000000000000000000000000000004 // name 参数的长度为4字节
3078414100000000000000000000000000000000000000000000000000000000 // name
其中 name
参数被转换为UTF-8的字节值 0x30784141
,在 abi 编码规范中,string 属于动态类型 ,动态类型的参数需要借助偏移量进行编码,可以参考动态类型的使用。由于 abi.encode 会将每个参与编码的参数元素(包括偏移量,长度)都填充为32字节(evm字长为32字节),所以可以看到编码后的数据中有很多填充的 0 。
abi.encodePacked
将给定参数根据其所需最低空间编码。它类似 abi.encode
,但是会把其中填充的很多0
省略。比如,只用1字节来编码uint8
类型。当你想省空间,并且不与合约交互的时候,可以使用abi.encodePacked
,例如算一些数据的hash
时。需要注意,abi.encodePacked
因为不会做填充,所以不同的输入在拼接后可能会产生相同的编码结果,导致冲突,这也带来了潜在的安全风险。
function encodePacked() public view returns(bytes memory result) {
result = abi.encodePacked(x, addr, name, array);
}
编码的结果为0x000000000000000000000000000000000000000000000000000000000000000a7a58c0be72be218b41c608b7fe7c5bb630736c713078414100000000000000000000000000000000000000000000000000000000000000050000000000000000000000000000000000000000000000000000000000000006
,由于abi.encodePacked
对编码进行了压缩,长度比abi.encode
短很多。
abi.encodeWithSignature
与abi.encode
功能类似,只不过第一个参数为函数签名
,比如"foo(uint256,address,string,uint256[2])"
。当调用其他合约的时候可以使用。
function encodeWithSignature() public view returns(bytes memory result) {
result = abi.encodeWithSignature("foo(uint256,address,string,uint256[2])", x, addr, name, array);
}
编码的结果为0xe87082f1000000000000000000000000000000000000000000000000000000000000000a0000000000000000000000007a58c0be72be218b41c608b7fe7c5bb630736c7100000000000000000000000000000000000000000000000000000000000000a00000000000000000000000000000000000000000000000000000000000000005000000000000000000000000000000000000000000000000000000000000000600000000000000000000000000000000000000000000000000000000000000043078414100000000000000000000000000000000000000000000000000000000
,等同于在abi.encode
编码结果前加上了4字节的函数选择器
1。
abi.encodeWithSelector
与abi.encodeWithSignature
功能类似,只不过第一个参数为函数选择器
,为函数签名
Keccak哈希的前4个字节。
function encodeWithSelector() public view returns(bytes memory result) {
result = abi.encodeWithSelector(bytes4(keccak256("foo(uint256,address,string,uint256[2])")), x, addr, name, array);
}
编码的结果为0xe87082f1000000000000000000000000000000000000000000000000000000000000000a0000000000000000000000007a58c0be72be218b41c608b7fe7c5bb630736c7100000000000000000000000000000000000000000000000000000000000000a00000000000000000000000000000000000000000000000000000000000000005000000000000000000000000000000000000000000000000000000000000000600000000000000000000000000000000000000000000000000000000000000043078414100000000000000000000000000000000000000000000000000000000
,与abi.encodeWithSignature
结果一样。
ABI解码
abi.decode
abi.decode
用于解码abi.encode
生成的二进制编码,将它还原成原本的参数。
function decode(bytes memory data) public pure returns(uint dx, address daddr, string memory dname, uint[2] memory darray) {
(dx, daddr, dname, darray) = abi.decode(data, (uint, address, string, uint[2]));
}
我们将abi.encode
的二进制编码输入给decode
,将解码出原来的参数:
28 Hash
哈希函数(hash function)是一个密码学概念,它可以将任意长度的消息转换为一个固定长度的值,这个值也称作哈希(hash)。这一讲,我们简单介绍一下哈希函数及在Solidity
的应用。
Hash的性质
一个好的哈希函数应该具有以下几个特性:
- 单向性:从输入的消息到它的哈希的正向运算简单且唯一确定,而反过来非常难,只能靠暴力枚举。
- 灵敏性:输入的消息改变一点对它的哈希改变很大。
- 高效性:从输入的消息到哈希的运算高效。
- 均一性:每个哈希值被取到的概率应该基本相等。
- 抗碰撞性:
- 弱抗碰撞性:给定一个消息
x
,找到另一个消息x'
,使得hash(x) = hash(x')
是困难的。 - 强抗碰撞性:找到任意
x
和x'
,使得hash(x) = hash(x')
是困难的。
- 弱抗碰撞性:给定一个消息
Hash的应用
- 生成数据唯一标识
- 加密签名
- 安全加密
Keccak256
Keccak256
函数是Solidity
中最常用的哈希函数,用法非常简单:
哈希 = keccak256(数据);
Keccak256和sha3
这是一个很有趣的事情:
- sha3由keccak标准化而来,在很多场合下Keccak和SHA3是同义词,但在2015年8月SHA3最终完成标准化时,NIST调整了填充算法。所以SHA3就和keccak计算的结果不一样,这点在实际开发中要注意。
- 以太坊在开发的时候sha3还在标准化中,所以采用了keccak,所以Ethereum和Solidity智能合约代码中的SHA3是指Keccak256,而不是标准的NIST-SHA3,为了避免混淆,直接在合约代码中写成Keccak256是最清晰的。
生成数据唯一标识
我们可以利用keccak256
来生成一些数据的唯一标识。比如我们有几个不同类型的数据:uint
,string
,address
,我们可以先用abi.encodePacked
方法将他们打包编码,然后再用keccak256
来生成唯一标识:
function hash(
uint _num,
string memory _string,
address _addr
) public pure returns (bytes32) {
return keccak256(abi.encodePacked(_num, _string, _addr));
}
弱抗碰撞性
我们用keccak256
演示一下之前讲到的弱抗碰撞性,即给定一个消息x
,找到另一个消息x'
,使得hash(x) = hash(x')
是困难的。
我们给定一个消息0xAA
,试图去找另一个消息,使得它们的哈希值相等:
// 弱抗碰撞性
function weak(
string memory string1
)public view returns (bool){
return keccak256(abi.encodePacked(string1)) == _msg;
}
大家可以试个10次,看看能不能幸运的碰撞上。
强抗碰撞性
我们用keccak256
演示一下之前讲到的强抗碰撞性,即找到任意不同的x
和x'
,使得hash(x) = hash(x')
是困难的。
我们构造一个函数strong
,接收两个不同的string
参数string1
和string2
,然后判断它们的哈希是否相同:
// 强抗碰撞性
function strong(
string memory string1,
string memory string2
)public pure returns (bool){
return keccak256(abi.encodePacked(string1)) == keccak256(abi.encodePacked(string2));
}
大家可以试个10次,看看能不能幸运的碰撞上。
29 选择器
当我们调用智能合约时,本质上是向目标合约发送了一段calldata
,在remix中发送一次交易后,可以在详细信息中看见input
即为此次交易的calldata
发送的calldata
中前4个字节是selector
(函数选择器)。这一讲,我们将介绍selector
是什么,以及如何使用。
msg.data
msg.data
是Solidity
中的一个全局变量,值为完整的calldata
(调用函数时传入的数据)。
在下面的代码中,我们可以通过Log
事件来输出调用mint
函数的calldata
:
// event 返回msg.data
event Log(bytes data);
function mint(address to) external{
emit Log(msg.data);
}
当参数为0x2c44b726ADF1963cA47Af88B284C06f30380fC78
时,输出的calldata
为
0x6a6278420000000000000000000000002c44b726adf1963ca47af88b284c06f30380fc78
这段很乱的字节码可以分成两部分:
前4个字节为函数选择器selector:
0x6a627842
后面32个字节为输入的参数:
0x0000000000000000000000002c44b726adf1963ca47af88b284c06f30380fc78
其实calldata
就是告诉智能合约,我要调用哪个函数,以及参数是什么。
method id、selector和函数签名
method id
定义为函数签名
的Keccak
哈希后的前4个字节,当selector
与method id
相匹配时,即表示调用该函数,那么函数签名
是什么?
其实在第21讲中,我们简单介绍了函数签名,为"函数名(逗号分隔的参数类型)"
。举个例子,上面代码中mint
的函数签名为"mint(address)"
。在同一个智能合约中,不同的函数有不同的函数签名,因此我们可以通过函数签名来确定要调用哪个函数。
注意,在函数签名中,uint
和int
要写为uint256
和int256
。
我们写一个函数,来验证mint
函数的method id
是否为0x6a627842
。大家可以运行下面的函数,看看结果。
function mintSelector() external pure returns(bytes4 mSelector){
return bytes4(keccak256("mint(address)"));
}
结果正是0x6a627842
:
由于计算method id
时,需要通过函数名和函数的参数类型来计算。在Solidity
中,函数的参数类型主要分为:基础类型参数,固定长度类型参数,可变长度类型参数和映射类型参数。
基础类型参数
solidity
中,基础类型的参数有:uint256
(uint8
, ... , uint256
)、bool
, address
等。在计算method id
时,只需要计算bytes4(keccak256("函数名(参数类型1,参数类型2,...)"))
。例如,如下函数,函数名为elementaryParamSelector
,参数类型分别为uint256
和bool
。所以,只需要计算bytes4(keccak256("elementaryParamSelector(uint256,bool)"))
便可得到此函数的method id
。
// elementary(基础)类型参数selector
// 输入:param1: 1,param2: 0
// elementaryParamSelector(uint256,bool) : 0x3ec37834
function elementaryParamSelector(uint256 param1, bool param2) external returns(bytes4 selectorWithElementaryParam){
emit SelectorEvent(this.elementaryParamSelector.selector);
return bytes4(keccak256("elementaryParamSelector(uint256,bool)"));
}
固定长度类型参数
固定长度的参数类型通常为固定长度的数组,例如:uint256[5]
等。例如,如下函数fixedSizeParamSelector
的参数为uint256[3]
。因此,在计算该函数的method id
时,只需要通过bytes4(keccak256("fixedSizeParamSelector(uint256[3])"))
即可。
// fixed size(固定长度)类型参数selector
// 输入: param1: [1,2,3]
// fixedSizeParamSelector(uint256[3]) : 0xead6b8bd
function fixedSizeParamSelector(uint256[3] memory param1) external returns(bytes4 selectorWithFixedSizeParam){
emit SelectorEvent(this.fixedSizeParamSelector.selector);
return bytes4(keccak256("fixedSizeParamSelector(uint256[3])"));
}
可变长度类型参数
可变长度参数类型通常为可变长的数组,例如:address[]
、uint8[]
、string
等。例如,如下函数nonFixedSizeParamSelector
的参数为uint256[]
和string
。因此,在计算该函数的method id
时,只需要通过bytes4(keccak256("nonFixedSizeParamSelector(uint256[],string)"))
即可。
// non-fixed size(可变长度)类型参数selector
// 输入: param1: [1,2,3], param2: "abc"
// nonFixedSizeParamSelector(uint256[],string) : 0xf0ca01de
function nonFixedSizeParamSelector(uint256[] memory param1,string memory param2) external returns(bytes4 selectorWithNonFixedSizeParam){
emit SelectorEvent(this.nonFixedSizeParamSelector.selector);
return bytes4(keccak256("nonFixedSizeParamSelector(uint256[],string)"));
}
映射类型参数
映射类型参数通常有:contract
、enum
、struct
等。在计算method id
时,需要将该类型转化成为ABI
类型。
例如,如下函数mappingParamSelector
中DemoContract
需要转化为address
,结构体User
需要转化为tuple
类型(uint256,bytes)
,枚举类型School
需要转化为uint8
。因此,计算该函数的method id
的代码为bytes4(keccak256("mappingParamSelector(address,(uint256,bytes),uint256[],uint8)"))
。
contract DemoContract {
// empty contract
}
contract Selector{
// Struct User
struct User {
uint256 uid;
bytes name;
}
// Enum School
enum School { SCHOOL1, SCHOOL2, SCHOOL3 }
...
// mapping(映射)类型参数selector
// 输入:demo: 0x9D7f74d0C41E726EC95884E0e97Fa6129e3b5E99, user: [1, "0xa0b1"], count: [1,2,3], mySchool: 1
// mappingParamSelector(address,(uint256,bytes),uint256[],uint8) : 0xe355b0ce
function mappingParamSelector(DemoContract demo, User memory user, uint256[] memory count, School mySchool) external returns(bytes4 selectorWithMappingParam){
emit SelectorEvent(this.mappingParamSelector.selector);
return bytes4(keccak256("mappingParamSelector(address,(uint256,bytes),uint256[],uint8)"));
}
...
}
使用selector
我们可以利用selector
来调用目标函数。例如我想调用elementaryParamSelector
函数,我只需要利用abi.encodeWithSelector
将elementaryParamSelector
函数的method id
作为selector
和参数打包编码,传给call
函数:
// 使用selector来调用函数
function callWithSignature() external{
...
// 调用elementaryParamSelector函数
(bool success1, bytes memory data1) = address(this).call(abi.encodeWithSelector(0x3ec37834, 1, 0));
...
}
在日志中,我们可以看到elementaryParamSelector
函数被成功调用,并输出Log
事件。
总结
这一讲,我们介绍了什么是函数选择器
(selector
),它和msg.data
、函数签名
的关系,以及如何使用它调用目标函数。
30 Try Catch
try-catch
是现代编程语言几乎都有的处理异常的一种标准方式,Solidity
0.6版本也添加了它。这一讲,我们将介绍如何利用try-catch
处理智能合约中的异常。
try-catch
在Solidity
中,try-catch
只能被用于external
函数或public
函数或创建合约时constructor
(被视为external
函数)的调用。基本语法如下:
try externalContract.f() {
// call成功的情况下 运行一些代码
} catch {
// call失败的情况下 运行一些代码
}
其中externalContract.f()
是某个外部合约的函数调用,try
模块在调用成功的情况下运行,而catch
模块则在调用失败时运行。
同样可以使用this.f()
来替代externalContract.f()
,this.f()
也被视作为外部调用,但不可在构造函数中使用,因为此时合约还未创建。
如果调用的函数有返回值,那么必须在try
之后声明returns(returnType val)
,并且在try
模块中可以使用返回的变量;如果是创建合约,那么返回值是新创建的合约变量。
try externalContract.f() returns(returnType val){
// call成功的情况下 运行一些代码
} catch {
// call失败的情况下 运行一些代码
}
另外,catch
模块支持捕获特殊的异常原因:
try externalContract.f() returns(returnType){
// call成功的情况下 运行一些代码
} catch Error(string memory /*reason*/) {
// 捕获revert("reasonString") 和 require(false, "reasonString")
} catch Panic(uint /*errorCode*/) {
// 捕获Panic导致的错误 例如assert失败 溢出 除零 数组访问越界
} catch (bytes memory /*lowLevelData*/) {
// 如果发生了revert且上面2个异常类型匹配都失败了 会进入该分支
// 例如revert() require(false) revert自定义类型的error
}
try-catch
实战
OnlyEven
我们创建一个外部合约OnlyEven
,并使用try-catch
来处理异常:
contract OnlyEven{
constructor(uint a){
require(a != 0, "invalid number");
assert(a != 1);
}
function onlyEven(uint256 b) external pure returns(bool success){
// 输入奇数时revert
require(b % 2 == 0, "Ups! Reverting");
success = true;
}
}
OnlyEven
合约包含一个构造函数和一个onlyEven
函数。
- 构造函数有一个参数
a
,当a=0
时,require
会抛出异常;当a=1
时,assert
会抛出异常;其他情况均正常。 onlyEven
函数有一个参数b
,当b
为奇数时,require
会抛出异常。
处理外部函数调用异常
首先,在TryCatch
合约中定义一些事件和状态变量:
// 成功event
event SuccessEvent();
// 失败event
event CatchEvent(string message);
event CatchByte(bytes data);
// 声明OnlyEven合约变量
OnlyEven even;
constructor() {
even = new OnlyEven(2);
}
SuccessEvent
是调用成功会释放的事件,而CatchEvent
和CatchByte
是抛出异常时会释放的事件,分别对应require/revert
和assert
异常的情况。even
是个OnlyEven
合约类型的状态变量。
然后我们在execute
函数中使用try-catch
处理调用外部函数onlyEven
中的异常:
// 在external call中使用try-catch
function execute(uint amount) external returns (bool success) {
try even.onlyEven(amount) returns(bool _success){
// call成功的情况下
emit SuccessEvent();
return _success;
} catch Error(string memory reason){
// call不成功的情况下
emit CatchEvent(reason);
}
}
在remix上验证,处理外部函数调用异常
当运行execute(0)
的时候,因为0
为偶数,满足require(b % 2 == 0, "Ups! Reverting");
,没有异常抛出,调用成功并释放SuccessEvent
事件。
当运行execute(1)
的时候,因为1
为奇数,不满足require(b % 2 == 0, "Ups! Reverting");
,异常抛出,调用失败并释放CatchEvent
事件。
处理合约创建异常
这里,我们利用try-catch
来处理合约创建时的异常。只需要把try
模块改写为OnlyEven
合约的创建就行:
// 在创建新合约中使用try-catch (合约创建被视为external call)
// executeNew(0)会失败并释放`CatchEvent`
// executeNew(1)会失败并释放`CatchByte`
// executeNew(2)会成功并释放`SuccessEvent`
function executeNew(uint a) external returns (bool success) {
try new OnlyEven(a) returns(OnlyEven _even){
// call成功的情况下
emit SuccessEvent();
success = _even.onlyEven(a);
} catch Error(string memory reason) {
// catch失败的 revert() 和 require()
emit CatchEvent(reason);
} catch (bytes memory reason) {
// catch失败的 assert()
emit CatchByte(reason);
}
}
在remix上验证,处理合约创建异常
当运行executeNew(0)
时,因为0
不满足require(a != 0, "invalid number");
,会失败并释放CatchEvent
事件。
当运行executeNew(1)
时,因为1
不满足assert(a != 1);
,会失败并释放CatchByte
事件。
当运行executeNew(2)
时,因为2
满足require(a != 0, "invalid number");
和assert(a != 1);
,会成功并释放SuccessEvent
事件。
总结
在这一讲,我们介绍了如何在Solidity
使用try-catch
来处理智能合约运行中的异常:
- 只能用于外部合约调用和合约创建。
- 如果
try
执行成功,返回变量必须声明,并且与返回的变量类型相同。