Solidity8.0-进阶
Solidity8.0-进阶
重载
solidity中允许函数进行重载(overloading),即名字相同但输入参数类型不同的函数可以同时存在,他们被视为不同的函数。注意,solidity不允许修饰器(modifier)重载。
函数重载
举个例子,我们可以定义两个都叫saySomething()的函数,一个没有任何参数,输出"Nothing";另一个接收一个string参数,输出这个string。
1 | function saySomething() public pure returns(string memory){ |
最终重载函数在经过编译器编译后,由于不同的参数类型,都变成了不同的函数选择器(selector)。
以 Overloading.sol 合约为例,在 Remix 上编译部署后,分别调用重载函数 saySomething() 和 saySomething(string memory something),可以看到他们返回了不同的结果,被区分为不同的函数。
实参匹配(Argument Matching)
在调用重载函数时,会把输入的实际参数和函数参数的变量类型做匹配。 如果出现多个匹配的重载函数,则会报错。下面这个例子有两个叫f()的函数,一个参数为uint8,另一个为uint256:
1 | function f(uint8 _in) public pure returns (uint8 out) { |
我们调用f(50),因为50既可以被转换为uint8,也可以被转换为uint256,因此会报错。
库函数
库函数是一种特殊的合约,为了提升solidity代码的复用性和减少gas而存在。库合约一般都是一些好用的函数合集(库函数),由大神或者项目方创作,咱们站在巨人的肩膀上,会用就行了。
他和普通合约主要有以下几点不同:
- 不能存在状态变量
- 不能够继承或被继承
- 不能接收以太币
- 不可以被销毁
String库合约
String库合约是将uint256类型转换为相应的string类型的代码库,样例代码如下:
1 | library Strings { |
他主要包含两个函数,toString()将uint256转为string,toHexString()将uint256转换为16进制,在转换为string。
如何使用库合约
我们用String库函数的toHexString()来演示两种使用库合约中函数的办法。
1. 利用using for指令
指令using A for B;可用于附加库函数(从库 A)到任何类型(B)。添加完指令后,库A中的函数会自动添加为B类型变量的成员,可以直接调用。注意:在调用的时候,这个变量会被当作第一个参数传递给函数:
1 | // 利用using for指令 |
2. 通过库合约名称调用库函数
1 | // 直接通过库合约名调用 |
我们部署合约并输入170测试一下,两种方法均能返回正确的16进制string “0xaa”。证明我们调用库函数成功!
常用库
只需要知道什么情况该用什么库合约。常用的有:
- [String]:将
uint256转换为String - [Address]:判断某个地址是否为合约地址
- [Create2]:更安全的使用
Create2 EVM opcode - [Arrays]:跟数组相关的库函数
import
solidity支持利用import关键字导入其他源代码中的合约,让开发更加模块化。
- 通过源文件相对位置导入,例子:
1 | 文件结构 |
- 通过源文件网址导入网上的合约,例子:
1 | // 通过网址引用 |
- 通过
npm的目录导入,例子:
1 | import '@openzeppelin/contracts/access/Ownable.sol'; |
- 通过
全局符号导入特定的合约,例子:
1 | import {Yeye} from './Yeye.sol'; |
- 引用(
import)在代码中的位置为:在声明版本号之后,在其余代码之前。
接收ETH
介绍Solidity中的两种特殊函数,receive()和fallback(),他们主要在两种情况下被使用,他们主要用于处理接收ETH和代理合约proxy contract。
Solidity支持两种特殊的回调函数,receive()和fallback(),他们主要在两种情况下被使用:
- 接收ETH
- 处理合约中不存在的函数调用(代理合约proxy contract)
注意:在solidity 0.6.x版本之前,语法上只有 fallback() 函数,用来接收用户发送的ETH时调用以及在被调用函数签名没有匹配到时,来调用。 0.6版本之后,solidity才将 fallback() 函数拆分成 receive() 和 fallback() 两个函数。
接收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,例如:
1 | // 定义事件 |
有些恶意合约,会在receive() 函数(老版本的话,就是 fallback() 函数)嵌入恶意消耗gas的内容或者使得执行故意失败的代码,导致一些包含退款和转账逻辑的合约不能正常工作,因此写包含退款等逻辑的合约时候,一定要注意这种情况。
回退函数 fallback
fallback()函数会在调用合约不存在的函数时被触发。可用于接收ETH,也可以用于代理合约proxy contract。fallback()声明时不需要function关键字,必须由external修饰,一般也会用payable修饰,用于接收ETH:fallback() external payable { ... }。
我们定义一个fallback()函数,被触发时候会释放fallbackCalled事件,并输出msg.sender,msg.value和msg.data:
1 | // fallback |
receive和fallback的区别
receive和fallback都能够用于接收ETH,他们触发的规则如下:
1 | 触发fallback() 还是 receive()? |
简单来说,合约接收ETH时,msg.data为空且存在receive()时,会触发receive();msg.data不为空或不存在receive()时,会触发fallback(),此时fallback()必须为payable。
receive()和payable fallback()均不存在的时候,向合约发送ETH将会报错。
本节代码
1 | // SPDX-License-Identifier: MIT |
在 Remix 上部署合约 “Fallback.sol”。”VALUE” 栏中填入要发送给合约的金额(单位是 Wei),然后点击 “Transact”。可以看到交易成功,并且触发了 “receivedCalled” 事件。”VALUE” 栏中填入要发送给合约的金额(单位是 Wei),”CALLDATA” 栏中填入随意编写的msg.data,然后点击 “Transact”。可以看到交易成功,并且触发了 “fallbackCalled” 事件。
发送ETH
Solidity有三种方法向其他合约发送ETH,他们是:transfer(),send()和call(),其中call()是被鼓励的用法。
接收ETH合约
我们先部署一个接收ETH合约ReceiveETH。ReceiveETH合约里有一个事件Log,记录收到的ETH数量和gas剩余。还有两个函数,一个是receive()函数,收到ETH被触发,并发送Log事件;另一个是查询合约ETH余额的getBalance()函数。
1 | contract ReceiveETH { |
部署ReceiveETH合约后,运行getBalance()函数,可以看到当前合约的ETH余额为0。
发送ETH合约
我们将实现三种方法向ReceiveETH合约发送ETH。首先,先在发送ETH合约SendETH中实现payable的构造函数和receive(),让我们能够在部署时和部署后向合约转账。
1 | contract SendETH { |
transfer
- 用法是:
接收方地址.transfer(发送ETH数额)。 transfer()的gas限制是2300,足够用于转账,但对方合约的fallback()或receive()函数不能实现太复杂的逻辑。transfer()如果转账失败,会自动revert(回滚交易)。
代码样例,注意里面的_to填ReceiveETH合约的地址,amount是ETH转账金额:
1 | // 用transfer()发送ETH |
部署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,代表着转账成功或失败,需要额外代码处理一下。
代码样例:
1 | // send()发送ETH |
对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, data),其中bool代表着转账成功或失败,需要额外代码处理一下。
代码样例:
1 | // call()发送ETH |
对ReceiveETH合约发送ETH,此时amount为10,value为0,amount>value,转账失败,因为经过处理,所以发生revert。
此时amount为10,value为11,amount<=value,转账成功。
运行三种方法,可以看到,他们都可以成功地向ReceiveETH合约发送ETH。
本节代码
1 | // SPDX-License-Identifier: MIT |
总结
介绍solidity三种发送ETH的方法:transfer,send和call。
call没有gas限制,最为灵活,是最提倡的方法;transfer有2300 gas限制,但是发送失败会自动revert交易,是次优选择;send有2300 gas限制,而且发送失败不会自动revert交易,几乎没有人用它。
调用已部署合约
开发者写智能合约来调用其他合约,这让以太坊网络上的程序可以复用,从而建立繁荣的生态。很多web3项目依赖于调用其他合约,比如收益农场(yield farming)。这一讲,我们介绍如何在已知合约代码(或接口)和地址情况下调用目标合约的函数。
目标合约
先写一个简单的合约OtherContract来调用。
1 | contract OtherContract { |
这个合约包含一个状态变量_x,一个事件Log在收到ETH时触发,三个函数:
getBalance(): 返回合约ETH余额。setX():external payable函数,可以设置_x的值,并向合约发送ETH。getX(): 读取_x的值。
调用OtherContract合约
我们可以利用合约的地址和合约代码(或接口)来创建合约的引用:_Name(_Address),其中_Name是合约名,_Address是合约地址。然后用合约的引用来调用它的函数:_Name(_Address).f(),其中f()是要调用的函数。
下面我们介绍4个调用合约的例子,在remix中编译合约后,在CONTRACT处分别部署OtherContract和CallContract。
1. 传入合约地址
我们可以在函数里传入目标合约地址,生成目标合约的引用,然后调用目标函数。以调用OtherContract合约的setX函数为例,我们在新合约中写一个callSetX函数,传入已部署好的OtherContract合约地址_Address和setX的参数x:
1 | function callSetX(address _Address, uint256 x) external{ |
复制OtherContract合约的地址,填入callSetX函数的参数中,成功调用后,调用OtherContract合约中的getX验证x变为123
2. 传入合约变量
我们可以直接在函数里传入合约的引用,只需要把上面参数的address类型改为目标合约名,比如OtherContract。下面例子实现了调用目标合约的getX()函数。
注意该函数参数OtherContract _Address底层类型仍然是address,生成的ABI中、调用callGetX时传入的参数都是address类型
1 | function callGetX(OtherContract _Address) external view returns(uint x){ |
复制OtherContract合约的地址,填入callGetX函数的参数中,调用后成功获取x的值
3. 创建合约变量
我们可以创建合约变量,然后通过它来调用目标函数。下面例子,我们给变量oc存储了OtherContract合约的引用:
1 | function callGetX2(address _Address) external view returns(uint x){ |
复制OtherContract合约的地址,填入callGetX2函数的参数中,调用后成功获取x的值
4. 调用合约并发送ETH
如果目标合约的函数是payable的,那么我们可以通过调用它来给合约转账:_Name(_Address).f{value: _Value}(),其中_Name是合约名,_Address是合约地址,f是目标函数名,_Value是要转的ETH数额(以wei为单位)。
OtherContract合约的setX函数是payable的,在下面这个例子中我们通过调用setX来往目标合约转账。
1 | function setXTransferETH(address otherContract, uint256 x) payable external{ |
复制OtherContract合约的地址,填入setXTransferETH函数的参数中,并转入10ETH
转账后,我们可以通过Log事件和getBalance()函数观察目标合约ETH余额的变化。
本节代码
1 | // SPDX-License-Identifier: MIT |
Call
call 是address类型的低级成员函数,它用来与其他合约交互。它的返回值为(bool, data),分别对应call是否成功以及目标函数的返回值。
call是solidity官方推荐的通过触发fallback或receive函数发送ETH的方法。- 不推荐用
call来调用另一个合约,因为当你调用不安全合约的函数时,你就把主动权交给了它。推荐的方法仍是声明合约变量后调用函数 - 当我们不知道对方合约的源代码或
ABI,就没法生成合约变量;这时,我们仍可以通过call调用对方合约的函数。
Call的使用规则
call的使用规则如下:
1 | 目标合约地址.call(二进制编码); |
其中二进制编码利用结构化编码函数abi.encodeWithSignature获得:
1 | abi.encodeWithSignature("函数签名", 逗号分隔的具体参数) |
函数签名为"函数名(逗号分隔的参数类型)"。例如abi.encodeWithSignature("f(uint256,address)", _x, _addr)。
另外call在调用合约时可以指定交易发送的ETH数额和gas:
1 | 目标合约地址.call{value:发送数额, gas:gas数额}(二进制编码); |
目标合约
我们先写一个简单的目标合约OtherContract并部署
1 | contract OtherContract { |
这个合约包含一个状态变量x,一个在收到ETH时触发的事件Log,三个函数:
getBalance(): 返回合约ETH余额。setX():external payable函数,可以设置x的值,并向合约发送ETH。getX(): 读取x的值。
利用Call调用目标合约
1. Response事件
我们写一个Call合约来调用目标合约函数。首先定义一个Response事件,输出call返回的success和data,方便我们观察返回值。
1 | // 定义Response事件,输出call返回的结果success和data |
2. 调用setX函数
我们定义callSetX函数来调用目标合约的setX(),转入msg.value数额的ETH,并释放Response事件输出success和data:
1 | function callSetX(address payable _addr, uint256 x) public payable { |
接下来我们调用callSetX把状态变量_x改为5,参数为OtherContract地址和5,由于目标函数setX()没有返回值,因此Response事件输出的data为0x,也就是空。
3. 调用getX函数
下面我们调用getX()函数,它将返回目标合约_x的值,类型为uint256。我们可以利用abi.decode来解码call的返回值data,并读出数值。
1 | function callGetX(address _addr) external returns(uint256){ |
从Response事件的输出,我们可以看到data为0x0000000000000000000000000000000000000000000000000000000000000005。而经过abi.decode,最终返回值为5。
4. 调用不存在的函数
如果我们给call输入的函数不存在于目标合约,那么目标合约的fallback函数会被触发。
1 | function callNonExist(address _addr) external{ |
上面例子中,我们call了不存在的foo函数。call仍能执行成功,并返回success,但其实调用的目标合约fallback函数。
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的变量上。

大家可以这样理解:一个富商把它的资产(状态变量)都交给一个VC代理(目标合约的函数)来打理。执行的是VC的函数,但是改变的是富商的状态。
delegatecall语法和call类似,也是:
1 | 目标合约地址.delegatecall(二进制编码); |
其中二进制编码利用结构化编码函数abi.encodeWithSignature获得:
1 | 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。
1 | // 被调用的合约C |
发起调用的合约B
首先,合约B必须和目标合约C的变量存储布局必须相同,两个变量,并且顺序为num和sender
1 | contract B { |
接下来,我们分别用call和delegatecall来调用合约C的setVars函数,更好的理解它们的区别。
callSetVars函数通过call来调用setVars。它有两个参数_addr和_num,分别对应合约C的地址和setVars的参数。
1 | // 通过call来调用C的setVars()函数,将改变合约C里的状态变量 |
而delegatecallSetVars函数通过delegatecall来调用setVars。与上面的callSetVars函数相同,有两个参数_addr和_num,分别对应合约C的地址和setVars的参数。
1 | // 通过delegatecall来调用C的setVars()函数,将改变合约B里的状态变量 |
本节代码
1 | // SPDX-License-Identifier: MIT |
在合约中创建新合约
在以太坊链上,用户(外部账户,EOA)可以创建智能合约,智能合约同样也可以创建新的智能合约。去中心化交易所uniswap就是利用工厂合约(Factory)创建了无数个币对合约(Pair)。这一讲,我会用简化版的uniswap讲如何通过合约创建合约。
有两种方法可以在合约中创建新合约,create和create2,这里我们讲create,下一讲会介绍create2。
Create
create的用法很简单,就是new一个合约,并传入新合约构造函数所需的参数:
1 | Contract x = new Contract{value: _value}(params) |
其中Contract是要创建的合约名,x是合约对象(地址),如果构造函数是payable,可以创建时转入_value数量的ETH,params是新合约构造函数的参数。
极简Uniswap
Uniswap V2核心合约中包含两个合约:
- UniswapV2Pair: 币对合约,用于管理币对地址、流动性、买卖。
- UniswapV2Factory: 工厂合约,用于创建新的币对,并管理币对地址。
下面我们用create方法实现一个极简版的Uniswap:Pair币对合约负责管理币对地址,PairFactory工厂合约用于创建新的币对,并管理币对地址。
Pair合约
1 | contract Pair{ |
Pair合约很简单,包含3个状态变量:factory,token0和token1。
构造函数constructor在部署时将factory赋值为工厂合约地址。initialize函数会在Pair合约创建的时候被工厂合约调用一次,将token0和token1更新为币对中两种代币的地址。
提问:为什么
uniswap不在constructor中将token0和token1地址更新好?答:因为
uniswap使用的是create2创建合约,限制构造函数不能有参数。当使用create时,Pair合约允许构造函数有参数,可以在constructor中将token0和token1地址更新好。
PairFactory
1 | contract PairFactory{ |
工厂合约(PairFactory)有两个状态变量getPair是两个代币地址到币对地址的map,方便根据代币找到币对地址;allPairs是币对地址的数组,存储了所有代币地址。
PairFactory合约只有一个createPair函数,根据输入的两个代币地址tokenA和tokenB来创建新的Pair合约。其中
1 | Pair pair = new Pair(); |
就是创建合约的代码,非常简单。大家可以部署好PairFactory合约,然后用下面两个地址作为参数调用createPair,看看创建的币对地址是什么:
1 | WBNB地址: 0x2c44b726ADF1963cA47Af88B284C06f30380fC78 |
Create2
CREATE2 操作码使我们在智能合约部署在以太坊网络之前就能预测合约的地址。Uniswap创建Pair合约用的就是CREATE2而不是CREATE。
CREATE如何计算地址
智能合约可以由其他合约和普通账户利用CREATE操作码创建。 在这两种情况下,新合约的地址都以相同的方式计算:创建者的地址(通常为部署的钱包地址或者合约地址)和nonce(该地址发送交易的总数,对于合约账户是创建的合约总数,每创建一个合约nonce+1))的哈希。
1 | 新地址 = hash(创建者地址, nonce) |
创建者地址不会变,但nonce可能会随时间而改变,因此用CREATE创建的合约地址不好预测。
CREATE2如何计算地址
CREATE2的目的是为了让合约地址独立于未来的事件。不管未来区块链上发生了什么,你都可以把合约部署在事先计算好的地址上。用CREATE2创建的合约地址由4个部分决定:
0xFF:一个常数,避免和CREATE冲突- 创建者地址
salt(盐):一个创建者给定的数值- 待部署合约的字节码(
bytecode)
1 | 新地址 = hash("0xFF",创建者地址, salt, bytecode) |
CREATE2 确保,如果创建者使用 CREATE2 和提供的 salt 部署给定的合约bytecode,它将存储在 新地址 中。
如何使用CREATE2
CREATE2的用法和之前讲的Create类似,同样是new一个合约,并传入新合约构造函数所需的参数,只不过要多传一个salt参数:
1 | Contract x = new Contract{salt: _salt, value: _value}(params) |
其中Contract是要创建的合约名,x是合约对象(地址),_salt是指定的盐;如果构造函数是payable,可以创建时转入_value数量的ETH,params是新合约构造函数的参数。
极简Uniswap2
跟上一讲类似,我们用Create2来实现极简Uniswap。
Pair
1 | contract Pair{ |
Pair合约很简单,包含3个状态变量:factory,token0和token1。
构造函数constructor在部署时将factory赋值为工厂合约地址。initialize函数会在Pair合约创建的时候被工厂合约调用一次,将token0和token1更新为币对中两种代币的地址。
PairFactory2
1 | contract PairFactory2{ |
工厂合约(PairFactory2)有两个状态变量getPair是两个代币地址到币对地址的map,方便根据代币找到币对地址;allPairs是币对地址的数组,存储了所有币对地址。
PairFactory2合约只有一个createPair2函数,使用CREATE2根据输入的两个代币地址tokenA和tokenB来创建新的Pair合约。其中
1 | Pair pair = new Pair{salt: salt}(); |
就是利用CREATE2创建合约的代码,非常简单,而salt为token1和token2的hash:
1 | bytes32 salt = keccak256(abi.encodePacked(token0, token1)); |
事先计算Pair地址
1 | // 提前计算pair合约地址 |
我们写了一个calculateAddr函数来事先计算tokenA和tokenB将会生成的Pair地址。通过它,我们可以验证我们事先计算的地址和实际地址是否相同。
大家可以部署好PairFactory2合约,然后用下面两个地址作为参数调用createPair2,看看创建的币对地址是什么,是否与事先计算的地址一样:
1 | WBNB地址: 0x2c44b726ADF1963cA47Af88B284C06f30380fC78 |
create2的实际应用场景
- 交易所为新用户预留创建钱包合约地址。
- 由
CREATE2驱动的factory合约,在uniswapV2中交易对的创建是在Factory中调用create2完成。这样做的好处是: 它可以得到一个确定的pair地址, 使得Router中就可以通过(tokenA, tokenB)计算出pair地址, 不再需要执行一次Factory.getPair(tokenA, tokenB)的跨合约调用。
删除合约
selfdestruct
selfdestruct命令可以用来删除智能合约,并将该合约剩余ETH转到指定地址。selfdestruct是为了应对合约出错的极端情况而设计的。它最早被命名为suicide(自杀),但是这个词太敏感。为了保护抑郁的程序员,改名为selfdestruct。
如何使用selfdestruct
selfdestruct使用起来非常简单:
1 | selfdestruct(_addr); |
其中_addr是接收合约中剩余ETH的地址。
例子
1 | contract DeleteContract { |
在DeleteContract合约中,我们写了一个public状态变量value,两个函数:getBalance()用于获取合约ETH余额,deleteContract()用于自毁合约,并把ETH转入给发起人。
部署好合约后,我们向DeleteContract合约转入1 ETH。这时,getBalance()会返回1 ETH,value变量是10。
当我们调用deleteContract()函数,合约将自毁,所有变量都清空,此时value变为默认值0,getBalance()也返回空值。
注意事项
- 对外提供合约销毁接口时,最好设置为只有合约所有者可以调用,可以使用函数修饰符
onlyOwner进行函数声明。 - 当合约被销毁后与智能合约的交互也能成功,并且返回0。
- 当合约中有
selfdestruct功能时常常会带来安全问题和信任问题,合约中的Selfdestruct功能会为攻击者打开攻击向量(例如使用selfdestruct向一个合约频繁转入token进行攻击,这将大大节省了GAS的费用,虽然很少人这么做),此外,此功能还会降低用户对合约的信心。
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, address, string, uint256[2]:
1 | uint x = 10; |
abi.encode
将给定参数利用ABI规则编码。ABI被设计出来跟智能合约交互,他将每个参数填充为32字节的数据,并拼接在一起。如果你要和合约交互,你要用的就是abi.encode。
1 | function encode() public view returns(bytes memory result) { |
编码的结果为0x000000000000000000000000000000000000000000000000000000000000000a0000000000000000000000007a58c0be72be218b41c608b7fe7c5bb630736c7100000000000000000000000000000000000000000000000000000000000000a00000000000000000000000000000000000000000000000000000000000000005000000000000000000000000000000000000000000000000000000000000000600000000000000000000000000000000000000000000000000000000000000043078414100000000000000000000000000000000000000000000000000000000,由于abi.encode将每个数据都填充为32字节,中间有很多0。
abi.encodePacked
将给定参数根据其所需最低空间编码。它类似 abi.encode,但是会把其中填充的很多0省略。比如,只用1字节来编码uint类型。当你想省空间,并且不与合约交互的时候,可以使用abi.encodePacked,例如算一些数据的hash时。
1 | function encodePacked() public view returns(bytes memory result) { |
编码的结果为0x000000000000000000000000000000000000000000000000000000000000000a7a58c0be72be218b41c608b7fe7c5bb630736c713078414100000000000000000000000000000000000000000000000000000000000000050000000000000000000000000000000000000000000000000000000000000006,由于abi.encodePacked对编码进行了压缩,长度比abi.encode短很多。
abi.encodeWithSignature
与abi.encode功能类似,只不过第一个参数为函数签名,比如"foo(uint256,address)"。当调用其他合约的时候可以使用。
1 | function encodeWithSignature() public view returns(bytes memory result) { |
编码的结果为0xe87082f1000000000000000000000000000000000000000000000000000000000000000a0000000000000000000000007a58c0be72be218b41c608b7fe7c5bb630736c7100000000000000000000000000000000000000000000000000000000000000a00000000000000000000000000000000000000000000000000000000000000005000000000000000000000000000000000000000000000000000000000000000600000000000000000000000000000000000000000000000000000000000000043078414100000000000000000000000000000000000000000000000000000000,等同于在abi.encode编码结果前加上了4字节的函数选择器说明。 说明: 函数选择器就是通过函数名和参数进行签名处理(Keccak–Sha3)来标识函数,可以用于不同合约之间的函数调用
abi.encodeWithSelector
与abi.encodeWithSignature功能类似,只不过第一个参数为函数选择器,为函数签名Keccak哈希的前4个字节。
1 | function encodeWithSelector() public view returns(bytes memory result) { |
编码的结果为0xe87082f1000000000000000000000000000000000000000000000000000000000000000a0000000000000000000000007a58c0be72be218b41c608b7fe7c5bb630736c7100000000000000000000000000000000000000000000000000000000000000a00000000000000000000000000000000000000000000000000000000000000005000000000000000000000000000000000000000000000000000000000000000600000000000000000000000000000000000000000000000000000000000000043078414100000000000000000000000000000000000000000000000000000000,与abi.encodeWithSignature结果一样。
ABI解码
abi.decode
abi.decode用于解码abi.encode生成的二进制编码,将它还原成原本的参数。
1 | function decode(bytes memory data) public pure returns(uint dx, address daddr, string memory dname, uint[2] memory darray) { |
我们将abi.encode的二进制编码输入给decode,将解码出原来的参数:
1 | // SPDX-License-Identifier: MIT |
ABI的使用场景
- 在合约开发中,ABI常配合call来实现对合约的底层调用。
1 | bytes4 selector = contract.getValue.selector; |
- ethers.js中常用ABI实现合约的导入和函数调用。
1 | const wavePortalContract = new ethers.Contract(contractAddress, contractABI, signer); |
- 对不开源合约进行反编译后,某些函数无法查到函数签名,可通过ABI进行调用。
- 0x533ba33a() 是一个反编译后显示的函数,只有函数编码后的结果,并且无法查到函数签名
- 这种情况无法通过构造interface接口或contract来进行调用
- 这种情况下,就可以通过ABI函数选择器来调用
1 | bytes memory data = abi.encodeWithSelector(bytes4(0x533ba33a)); |
Hash
哈希函数(hash function)是一个密码学概念,它可以将任意长度的消息转换为一个固定长度的值,这个值也称作哈希(hash)。这一讲,我们简单介绍一下哈希函数及在solidity的应用
Hash的性质
一个好的哈希函数应该具有以下几个特性:
- 单向性:从输入的消息到它的哈希的正向运算简单且唯一确定,而反过来非常难,只能靠暴力枚举。
- 灵敏性:输入的消息改变一点对它的哈希改变很大。
- 高效性:从输入的消息到哈希的运算高效。
- 均一性:每个哈希值被取到的概率应该基本相等。
- 抗碰撞性:
- 弱抗碰撞性:给定一个消息
x,找到另一个消息x'使得hash(x) = hash(x')是困难的。 - 强抗碰撞性:找到任意
x和x',使得hash(x) = hash(x')是困难的。
- 弱抗碰撞性:给定一个消息
Hash的应用
- 生成数据唯一标识
- 加密签名
- 安全加密
Keccak256
Keccak256函数是solidity中最常用的哈希函数,用法非常简单:
1 | 哈希 = 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来生成唯一标识:
1 | function hash( |
弱抗碰撞性
我们用keccak256演示一下之前讲到的弱抗碰撞性,即给定一个消息x,找到另一个消息x'使得hash(x) = hash(x')是困难的。
我们给定一个消息0xAA,试图去找另一个消息,使得它们的哈希值相等:
1 | // 弱抗碰撞性 |
强抗碰撞性
我们用keccak256演示一下之前讲到的强抗碰撞性,即找到任意不同的x和x',使得hash(x) = hash(x')是困难的。
我们构造一个函数strong,接收两个不同的string参数string1和string2,然后判断它们的哈希是否相同:
1 | // 强抗碰撞性 |
函数选择器Selector
当我们调用智能合约时,本质上是向目标合约发送了一段calldata,在remix中发送一次交易后,可以在详细信息中看见input即为此次交易的calldata发送的calldata中前4个字节是selector(函数选择器)。
msg.data[]
msg.data是solidity中的一个全局变量,值为完整的calldata(调用函数时传入的数据)。
在下面的代码中,我们可以通过Log事件来输出调用mint函数的calldata:
1 | // event 返回msg.data |
当参数为0x2c44b726ADF1963cA47Af88B284C06f30380fC78时,输出的calldata为
1 | 0x6a6278420000000000000000000000002c44b726adf1963ca47af88b284c06f30380fc78 |
这段很乱的字节码可以分成两部分:
1 | 前4个字节为函数选择器selector: |
其实calldata就是告诉智能合约,我要调用哪个函数,以及参数是什么。
method id、selector和函数签名
method id定义为函数签名的Keccak哈希后的前4个字节,当selector与method id相匹配时,即表示调用该函数,那么函数签名是什么?
函数签名:为"函数名(逗号分隔的参数类型)"。举个例子,上面代码中mint的函数签名为"mint(address)"。在同一个智能合约中,不同的函数有不同的函数签名,因此我们可以通过函数签名来确定要调用哪个函数。
注意,在函数签名中,uint和int要写为uint256和int256。
我们写一个函数,来验证mint函数的method id是否为0x6a627842。大家可以运行下面的函数,看看结果。
1 | function mintSelector() external pure returns(bytes4 mSelector){ |
结果正是0x6a627842:
使用selector
我们可以利用selector来调用目标函数。例如我想调用mint函数,我只需要利用abi.encodeWithSelector将mint函数的method id作为selector和参数打包编码,传给call函数:
1 | function callWithSignature() external returns(bool, bytes memory){ |
在日志中,我们可以看到mint函数被成功调用,并输出Log事件。
Try Catch
try-catch是现代编程语言几乎都有的处理异常的一种标准方式,solidity0.6版本也添加了它。这一讲,我们将介绍如何利用try-catch处理智能合约中的异常。
try-catch
在solidity中,try-catch只能被用于external函数或创建合约时constructor(被视为external函数)的调用。基本语法如下:
1 | try externalContract.f() { |
其中externalContract.f()是某个外部合约的函数调用,try模块在调用成功的情况下运行,而catch模块则在调用失败时运行。
同样可以使用this.f()来替代externalContract.f(),this.f()也被视作为外部调用,但不可在构造函数中使用,因为此时合约还未创建。
如果调用的函数有返回值,那么必须在try之后声明returns(returnType val),并且在try模块中可以使用返回的变量;如果是创建合约,那么返回值是新创建的合约变量。
1 | try externalContract.f() returns(returnType val){ |
另外,catch模块支持捕获特殊的异常原因:
1 | try externalContract.f() returns(returnType){ |
try-catch实战
OnlyEven
我们创建一个外部合约OnlyEven,并使用try-catch来处理异常:
1 | contract OnlyEven{ |
OnlyEven合约包含一个构造函数和一个onlyEven函数。
- 构造函数有一个参数
a,当a=0时,require会抛出异常;当a=1时,assert会抛出异常;其他情况均正常。 onlyEven函数有一个参数b,当b为奇数时,require会抛出异常。
处理外部函数调用异常
首先,在TryCatch合约中定义一些事件和状态变量:
1 | // 成功event |
SuccessEvent是调用成功会释放的事件,而CatchEvent和CatchByte是抛出异常时会释放的事件,分别对应require/revert和assert异常的情况。even是个OnlyEven合约类型的状态变量。
然后我们在execute函数中使用try-catch处理调用外部函数onlyEven中的异常:
1 | // 在external call中使用try-catch |
处理合约创建异常
这里,我们利用try-catch来处理合约创建时的异常。只需要把try模块改写为OnlyEven合约的创建就行:
1 | // 在创建新合约中使用try-catch (合约创建被视为external call) |
小结
介绍了如何在solidity使用try-catch来处理智能合约运行中的异常:
- 只能用于外部合约调用和合约创建。
- 如果
try执行成功,返回变量必须声明,并且与返回的变量类型相同。
1 | // SPDX-License-Identifier: MIT |

