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
是现代编程语言几乎都有的处理异常的一种标准方式,solidity
0.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 |