Solidity——速通学习

学习内容参考WTF

学习笔记,方便回忆

目录

学习内容参考WTF

1 代码初步了解

2 值类型

布尔型

整型

地址类型

 定长字节数组

枚举enum

3 函数

函数解析

 Pure和View

internal和external

payable

4 函数输出

返回值:return和returns

 命名式返回

 解构式赋值

5 变量数据存储和作用域

数据位置

 数据位置和赋值规则

变量的作用域

1. 状态变量

2. 局部变量

3. 全局变量

 4. 全局变量-以太单位与时间单位

6 引用类型

数组array

创建数组的规则 

数组成员

结构体struct 

7 映射类型

映射mapping

映射的规则

 映射的原理

8 变量初始值

值类型初始值

引用类型初始值

delete操作符

9 常数

constant和immutable

constant

 immutable

10 控制流

控制流

用Solidity实现插入排序

插入排序

11 构造函数和修饰器

构造函数

修饰器

OpenZeppelin的Ownable标准实现

12 事件

事件

声明事件

释放事件

EVM日志 Log

 主题 topics

数据 data

13 继承

规则

多重继承

修饰器的继承

构造函数的继承

 调用父合约的函数

 钻石继承

14 抽象合约和接口

抽象合约

接口

IERC721事件

IERC721函数

什么时候使用接口?

15 异常

异常

error

Require

Assert

三种方法的gas比较

16 函数重载

函数重载

实参匹配(Argument Matching) 

17 库合约

库合约

Strings库合约

如何使用库合约

常用合约

18 Import

import用法

测试导入结果

19 接收ETH

接收ETH函数 receive

回退函数 fallback

 receive和fallback的区别

20 发送ETH

接收ETH合约

发送ETH合约

 transfer

send

call

21 调用其他合约

目标合约

调用OtherContract合约

1. 传入合约地址

2. 传入合约变量

3. 创建合约变量

4. 调用合约并发送ETH

22 Call

Call

call的使用规则

目标合约

利用call调用目标合约

1. Response事件

2. 调用setX函数

3. 调用getX函数

4. 调用不存在的函数

23 Delegatecall

什么情况下会用到delegatecall?

delegatecall例子

被调用的合约C

发起调用的合约B

24 在合约中创建新合约

create

极简Uniswap

Pair合约

PairFactory

25 Create2

CREATE如何计算地址

CREATE2如何计算地址

如何使用CREATE2

极简Uniswap2

Pair

PairFactory2

事先计算Pair地址

如果部署合约构造函数中存在参数

26 删除合约

如何使用selfdestruct

Demo-转移ETH功能

注意事项

27 ABI编码解码

ABI编码

abi.encode

abi.encodePacked

abi.encodeWithSignature

abi.encodeWithSelector

ABI解码

abi.decode

28 Hash

Hash的性质

Hash的应用

Keccak256

Keccak256和sha3

生成数据唯一标识

弱抗碰撞性

强抗碰撞性

29 选择器

msg.data

method id、selector和函数签名

基础类型参数

固定长度类型参数

可变长度类型参数

映射类型参数

使用selector

总结

30 Try Catch

try-catch

try-catch实战

OnlyEven

处理外部函数调用异常

在remix上验证,处理外部函数调用异常

处理合约创建异常

在remix上验证,处理合约创建异常

总结

1 代码初步了解

Solidity 是一种用于编写以太坊虚拟机(EVM)智能合约的编程语言。

Remix 是以太坊官方推荐的智能合约集成开发环境(IDE),适合新手,可以在浏览器中快速开发和部署合约,无需在本地安装任何程序。

网址:https://remix.ethereum.org

第一个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) 是 trueg(y) 不会被计算,即使它和 f(x) 的结果是相反的。假如存在f(x) && g(y) 的表达式,如果 f(x) 是 falseg(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

 定长字节数组

字节数组分为定长和不定长两种:

  • 定长字节数组: 属于值类型,数组长度在声明之后不能改变。根据字节数组的长度分为 bytes1bytes8bytes32 等类型。定长字节数组最多存储 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> }

方括号中的是可写可不 写的关键字

  1. function:声明函数时的固定用法。要编写函数,就需要以 function 关键字开头。
  2. <function name>:函数名。
  3. ([parameter types[, ...]]):圆括号内写入函数的参数,即输入到函数的变量类型和名称。
  4. {internal|external|public|private}:函数可见性说明符,共有4种。
  5.  [pure|view|payable]:决定函数权限/功能的关键字。payable(可支付的)很好理解,带着它的函数,运行的时候可以给合约转入 ETH。pure 和 view 的介绍见下一节。
  6. [virtual|override]: 方法是否可以被重写,或者是否是重写方法。virtual用在父合约上,标识的方法可以被子合约重写。override用在自合约上,表名方法重写了父合约的方法。
  7. <modifiers>: 自定义的修饰器,可以有0个或多个修饰器。
  8. [returns ()]:函数返回的变量类型和名称。
  9. <function body>: 函数体。
  • 注意 1:合约中定义的函数需要明确指定可见性,它们没有默认值。
  • 注意 2public|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)。

在以太坊中,以下语句被视为修改链上状态:

  1. 写入状态变量。
  2. 释放事件。
  3. 创建其他合约。
  4. 使用 selfdestruct.
  5. 通过调用发送以太币。
  6. 调用任何未标记 view 或 pure 的函数。
  7. 使用低级调用(low-level calls)。
  8. 使用包含某些操作码的内联汇编。
// 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数据存储位置有三类:storagememorycalldata。不同存储位置的gas成本不同。storage类型的数据存在链上,类似计算机的硬盘,消耗gas多;memorycalldata类型的临时存在内存里,消耗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.senderblock.numbermsg.data,他们分别代表请求发起地址,当前区块高度,和请求数据。下面是一些常用的全局变量

  • blockhash(uint blockNumber): (bytes32) 给定区块的哈希值 – 只适用于最近的256个区块, 不包含当前区块。
  • block.coinbase: (address payable) 当前区块矿工的地址
  • block.gaslimit: (uint) 当前区块的gaslimit
  • block.number: (uint) 当前区块的number
  • block.timestamp: (uint) 当前区块的时间戳,为unix纪元以来的秒
  • gasleft(): (uint256) 剩余 gas
  • msg.data: (bytes calldata) 完整call data
  • msg.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: 1
  • gwei: 1e9 = 1000000000
  • ether: 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: 1
  • minutes: 60 seconds = 60
  • hours: 60 minutes = 3600
  • days: 24 hours = 86400
  • weeks: 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[]声明单字节数组,可以使用bytesbytes1[]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分别是KeyValue的变量类型。例子:

mapping(uint => address) public idToAddress; // id映射到地址
mapping(address => address) public swapPair; // 币对的映射,地址到地址

映射的规则

规则1:映射的_KeyType只能选择Solidity内置的值类型,比如uintaddress等,不能用自定义的结构体。而_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中,声明但没赋值的变量都有它的初始值或默认值。

值类型初始值

  • booleanfalse
  • string""
  • int0
  • uint0
  • enum: 枚举中的第一个元素
  • address0x0000000000000000000000000000000000000000 (或 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

另外,只有数值变量可以声明constantimmutablestringbytes可以声明为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,000 gas;相比之下,链上存储一个新变量至少需要20,000 gas

声明事件

事件的声明由event关键字开头,接着是事件名称,括号里面写好事件需要记录的变量类型和变量名。以ERC20代币合约的Transfer事件为例:

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

我们可以看到,Transfer事件共记录了3个变量fromtovalue,分别对应代币的转账地址,接收地址和转账数量,其中fromto前面带有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

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

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个functionhip()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的合约可以继承多个合约。规则:

  1. 继承时要按辈分最高到最低的顺序排。比如我们写一个Erzi合约,继承Yeye合约和Baba合约,那么就要写成contract Erzi is Yeye, Baba,而不能写成contract Erzi is Baba, Yeye,不然就会报错。
  2. 如果某一个函数在多个继承的合约里都存在,比如例子中的hip()pop(),在子合约里必须重写,不然会报错。
  3. 重写在多个父合约中都重名的函数时,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)同样可以继承,用法与函数继承类似,在相应的地方加virtualoverride关键字即可。

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;
    }
}
  1. 在继承时声明父构造函数的参数,例如:contract B is A(1)
  2. 在子合约的构造函数中声明构造函数的参数,例如:
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,再写AdamEve两个合约继承God合约,最后让创建合约people继承自AdamEve,每个合约都有foobar两个函数。

// 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(有向无环图)使其保证一个特定的顺序。

14 抽象合约和接口

ERC721的接口合约为例介绍Solidity中的抽象合约(abstract)和接口(interface

抽象合约

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

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

接口

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

  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都实现了这些函数。我们可以看到,接口和常规合约的区别在于每个函数都以;代替函数体{ }结尾。

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个事件,其中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

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三种抛出异常的方法:errorrequireassert,并比较三种方法的gas消耗。

异常

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

error

errorsolidity 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版本编译)

  1. error方法gas消耗:24457 (加入参数后gas消耗:24660)
  2. require方法gas消耗:24755
  3. assert方法gas消耗:24473

我们可以看到,error方法gas最少,其次是assertrequire方法消耗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而存在。库合约是一系列的函数合集,

他和普通合约主要有以下几点不同:

  1. 不能存在状态变量
  2. 不能够继承或被继承
  3. 不能接收以太币
  4. 不可以被销毁

需要注意的是,库合约中的函数可见性如果被设置为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进制的stringtoHexString()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”。证明我们调用库合约成功!

 

常用合约

  1. Strings:将uint256转换为String
  2. Address:判断某个地址是否为合约地址
  3. Create2:更安全的使用Create2 EVM opcode
  4. Arrays:跟数组相关的库合约

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(),他们主要在两种情况下被使用:

  1. 接收ETH
  2. 处理合约中不存在的函数调用(代理合约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()函数不能有任何的参数,不能返回任何值,必须包含externalpayable

当合约接收ETH的时候,receive()会被触发。receive()最好不要执行太多的逻辑因为如果别人用sendtransfer方法发送ETH的话,gas会限制在2300receive()太复杂可能会触发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 contractfallback()声明时不需要function关键字,必须由external修饰,一般也会用payable修饰,用于接收ETH:fallback() external payable { ... }

我们定义一个fallback()函数,被触发时候会释放fallbackCalled事件,并输出msg.sendermsg.valuemsg.data:

event fallbackCalled(address Sender, uint Value, bytes Data);

// fallback
fallback() external payable{
    emit fallbackCalled(msg.sender, msg.value, msg.data);
}

 receive和fallback的区别

receivefallback都能够用于接收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合约ReceiveETHReceiveETH合约里有一个事件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(回滚交易)。

代码样例,注意里面的_toReceiveETH合约的地址,amountETH转账金额:

// 用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限制,最为灵活,是最提倡的方法;
  • transfer2300 gas限制,但是发送失败会自动revert交易,是次优选择;
  • send2300 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中编译合约后,分别部署OtherContractCallContract

1. 传入合约地址

我们可以在函数里传入目标合约地址,生成目标合约的引用,然后调用目标函数。以调用OtherContract合约的setX函数为例,我们在新合约中写一个callSetX函数,传入已部署好的OtherContract合约地址_AddresssetX的参数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是否成功以及目标函数的返回值。

  • callSolidity官方推荐的通过触发fallbackreceive函数发送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返回的successdata,方便我们观察返回值。

// 定义Response事件,输出call返回的结果success和data
event Response(bool success, bytes data);
2. 调用setX函数

我们定义callSetX函数来调用目标合约的setX(),转入msg.value数额的ETH,并释放Response事件输出successdata

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事件输出的data0x,也就是空。 

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事件的输出,我们可以看到data0x0000000000000000000000000000000000000000000000000000000000000005。而经过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

delegatecallcall类似,是Solidity中地址类型的低级成员函数。delegate中是委托/代表的意思,那么delegatecall委托了什么?

当用户A通过合约Bcall合约C的时候,执行的是合约C的函数,上下文(Context,可以理解为包含变量和状态的环境)也是合约C的:msg.senderB的地址,并且如果函数改变一些状态变量,产生的效果会作用于合约C的变量上。

而当用户A通过合约Bdelegatecall合约C的时候,执行的是合约C的函数,但是上下文仍是合约B的:msg.senderA的地址,并且如果函数改变一些状态变量,产生的效果会作用于合约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主要有两个应用场景:

  1. 代理合约(Proxy Contract):将智能合约的存储合约和逻辑合约分开:代理合约(Proxy Contract)存储所有相关的变量,并且保存逻辑合约的地址;所有函数存在逻辑合约(Logic Contract)里,通过delegatecall执行。当升级时,只需要将代理合约指向新的逻辑合约即可。
  2. EIP-2535 Diamonds(钻石):钻石是一个支持构建可在生产中扩展的模块化智能合约系统的标准。钻石是具有多个实施合约的代理合约。

delegatecall例子

调用结构:你(A)通过合约B调用目标合约C

被调用的合约C

我们先写一个简单的目标合约C:有两个public变量:numsender,分别是uint256address类型;有一个函数,可以将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;
}

 接下来,我们分别用calldelegatecall来调用合约CsetVars函数,更好的理解它们的区别。

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

有两种方法可以在合约中创建新合约,createcreate2,这里我们讲create,下一讲会介绍create2

create的用法很简单,就是new一个合约,并传入新合约构造函数所需的参数:

Contract x = new Contract{value: _value}(params)

其中Contract是要创建的合约名,x是合约对象(地址),如果构造函数是payable,可以创建时转入_value数量的ETHparams是新合约构造函数的参数。

极简Uniswap

Uniswap V2核心合约中包含两个合约:

  1. UniswapV2Pair: 币对合约,用于管理币对地址、流动性、买卖。
  2. UniswapV2Factory: 工厂合约,用于创建新的币对,并管理币对地址。

下面我们用create方法实现一个极简版的UniswapPair币对合约负责管理币对地址,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个状态变量:factorytoken0token1

构造函数constructor在部署时将factory赋值为工厂合约地址。initialize函数会由工厂合约在部署完成后手动调用以初始化代币地址,将token0token1更新为币对中两种代币的地址。

提问:为什么uniswap不在constructor中将token0token1地址更新好?

:因为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函数,根据输入的两个代币地址tokenAtokenB来创建新的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数量的ETHparams是新合约构造函数的参数。

极简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个状态变量:factorytoken0token1

构造函数constructor在部署时将factory赋值为工厂合约地址。initialize函数会在Pair合约创建的时候被工厂合约调用一次,将token0token1更新为币对中两种代币的地址。

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根据输入的两个代币地址tokenAtokenB来创建新的Pair合约。其中

Pair pair = new Pair{salt: salt}();

就是利用CREATE2创建合约的代码,非常简单,而salttoken1token2hash

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函数来事先计算tokenAtokenB将会生成的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转移到指定地址,而原先的删除功能只有在合约创建-自毁这两个操作处在同一笔交易时才能生效。所以目前来说:

  1. 已经部署的合约无法被SELFDESTRUCT了。
  2. 如果要使用原先的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 ETHvalue变量是10。

当我们调用deleteContract()函数,合约将触发selfdestruct操作。在坎昆升级前,合约会被自毁。但是在升级后,合约依然存在,只是将合约包含的ETH转移到指定地址,而合约依然能够调用。

注意事项

  1. 对外提供合约销毁接口时,最好设置为只有合约所有者可以调用,可以使用函数修饰符onlyOwner进行函数声明。
  2. 当合约中有selfdestruct功能时常常会带来安全问题和信任问题,合约中的selfdestruct功能会为攻击者打开攻击向量(例如使用selfdestruct向一个合约频繁转入token进行攻击,这将大大节省了GAS的费用,虽然很少人这么做),此外,此功能还会降低用户对合约的信心。

27 ABI编码解码

ABI (Application Binary Interface,应用二进制接口)是与以太坊智能合约交互的标准。数据基于他们的类型编码;并且由于编码后不包含类型信息,解码时需要注明它们的类型。

Solidity中,ABI编码有4个函数:abi.encodeabi.encodePackedabi.encodeWithSignatureabi.encodeWithSelector。而ABI解码有1个函数:abi.decode,用于解码abi.encode的数据。这一讲,我们将学习如何使用这些函数。

ABI编码

我们将编码4个变量,他们的类型分别是uint256(别名 uint), addressstringuint256[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')是困难的。
    • 强抗碰撞性:找到任意xx',使得hash(x) = hash(x')是困难的。

Hash的应用

  • 生成数据唯一标识
  • 加密签名
  • 安全加密

Keccak256

Keccak256函数是Solidity中最常用的哈希函数,用法非常简单:

哈希 = keccak256(数据);

Keccak256和sha3

这是一个很有趣的事情:

  1. sha3由keccak标准化而来,在很多场合下Keccak和SHA3是同义词,但在2015年8月SHA3最终完成标准化时,NIST调整了填充算法。所以SHA3就和keccak计算的结果不一样,这点在实际开发中要注意。
  2. 以太坊在开发的时候sha3还在标准化中,所以采用了keccak,所以Ethereum和Solidity智能合约代码中的SHA3是指Keccak256,而不是标准的NIST-SHA3,为了避免混淆,直接在合约代码中写成Keccak256是最清晰的。

生成数据唯一标识

我们可以利用keccak256来生成一些数据的唯一标识。比如我们有几个不同类型的数据:uintstringaddress,我们可以先用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演示一下之前讲到的强抗碰撞性,即找到任意不同的xx',使得hash(x) = hash(x')是困难的。

我们构造一个函数strong,接收两个不同的string参数string1string2,然后判断它们的哈希是否相同:

// 强抗碰撞性
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.dataSolidity中的一个全局变量,值为完整的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个字节,当selectormethod id相匹配时,即表示调用该函数,那么函数签名是什么?

其实在第21讲中,我们简单介绍了函数签名,为"函数名(逗号分隔的参数类型)"。举个例子,上面代码中mint的函数签名为"mint(address)"。在同一个智能合约中,不同的函数有不同的函数签名,因此我们可以通过函数签名来确定要调用哪个函数。

注意,在函数签名中,uintint要写为uint256int256

我们写一个函数,来验证mint函数的method id是否为0x6a627842。大家可以运行下面的函数,看看结果。

function mintSelector() external pure returns(bytes4 mSelector){
    return bytes4(keccak256("mint(address)"));
}

结果正是0x6a627842

由于计算method id时,需要通过函数名和函数的参数类型来计算。在Solidity中,函数的参数类型主要分为:基础类型参数,固定长度类型参数,可变长度类型参数和映射类型参数。

基础类型参数

solidity中,基础类型的参数有:uint256(uint8, ... , uint256)、booladdress等。在计算method id时,只需要计算bytes4(keccak256("函数名(参数类型1,参数类型2,...)"))。例如,如下函数,函数名为elementaryParamSelector,参数类型分别为uint256bool。所以,只需要计算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)"));
    }
映射类型参数

映射类型参数通常有:contractenumstruct等。在计算method id时,需要将该类型转化成为ABI类型。

例如,如下函数mappingParamSelectorDemoContract需要转化为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.encodeWithSelectorelementaryParamSelector函数的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是现代编程语言几乎都有的处理异常的一种标准方式,Solidity0.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是调用成功会释放的事件,而CatchEventCatchByte是抛出异常时会释放的事件,分别对应require/revertassert异常的情况。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执行成功,返回变量必须声明,并且与返回的变量类型相同。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值