在合约中创建合约
在以太坊链上,用户(外部账户,EOA
)可以创建智能合约,智能合约同样也可以创建新的智能合约。去中心化交易所uniswap
就是利用工厂合约(Factory
)创建了无数个币对合约(Pair
)。这一讲,我会用简化版的uniswap
讲如何通过合约创建合约。
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
更新为币对中两种代币的地址。
PairFactory合约
1 | contract PairFactory{ |
工厂合约(PairFactory
)有两个状态变量getPair
是两个代币地址到币对地址的map
,方便根据代币找到币对地址;allPairs
是币对地址的数组,存储了所有代币地址。
PairFactory
合约只有一个createPair
函数,根据输入的两个代币地址tokenA
和tokenB
来创建新的Pair
合约。其中
1 | Pair pair = new Pair(); |
就是创建合约的代码,非常简单。
create2
create2
操作码使我们在智能合约部署到以太坊网络之前就能预测合约的地址。Uniswap
创建Pair
合约用的就是create2
。这一讲,我将介绍create2
的用法。
create如何计算地址
智能合约可以由其他合约和普通账户利用create
操作码创建。 在这两种情况下,新合约的地址都以相同的方式计算:创建者的地址和nonce
(该地址发送交易的总数)的哈希。
1 | 新地址 = hash(创建者地址, nonce); |
创建者地址不会变,但nounce
可能会随时间而改变,因此用create
创建的合约地址不好预测。
create2如何计算地址
create2
的目的是为了让合约地址独立于未来的事件。不管未来区块链上发生了什么,你都可以把合约部署在事先计算好的地址上。用create2
创建的合约地址由4个部分决定:
0xFF
:一个常数,避免和create
冲突- 创建者地址
salt
(盐):一个创建者给定的数值- 待部署合约的字节码(
bytecode
)
1 | 新地址 = hash("0xFF",创建者地址, nonce, 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合约
和上一节的一样
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
地址。通过它,我们可以验证我们事先计算的地址和实际地址是否相同。
调用合约
new 的方式
在已知合约代码(或接口)和地址情况下调用目标合约,
目标合约
我们先写一个简单的合约OtherContract
来调用。
1 | contract OtherContract { |
这个合约包含一个状态变量x
,一个事件Log
在收到ETH
时触发,三个函数:
getBalance()
: 返回合约ETH
余额。setX()
:external payable
函数,可以设置x
的值,并向合约发送ETH
。getX()
: 读取x
的值。
调用目标合约
我们可以利用合约的地址和合约代码(或接口)来创建合约的引用:_Name(_Address)
,其中_Name
是合约名,_Address
是合约地址。然后用合约的引用来调用它的函数:_Name(_Address).f()
,其中f()
是要调用的函数。
下面我们介绍4个调用合约的例子:
传入合约地址
我们可以在函数里传入目标合约地址,生成目标合约的引用,然后调用目标函数。以调用
OtherContract
合约的setX
函数为例,我们在新合约中写一个callSetX
函数,传入已部署好的OtherContract
合约地址_Address
和setX
的参数x
:1
2
3function callSetX(address _Address, uint256 x) external{
OtherContract(_Address).setX(x);
}传入合约变量
我们可以直接在函数里传入合约的引用,只需要把上面参数的
address
类型改为目标合约名,比OtherContract
。下面例子实现了调用目标合约的getX()
函数。1
2
3function callGetX(OtherContract _Address) external view returns(uint x){
x = _Address.getX();
}创建合约变量
我们可以创建合约变量,然后通过它来调用目标函数。下面例子,我们给变量
oc
存储了OtherContract
合约的引用:1
2
3
4function callGetX2(address _Address) external view returns(uint x){
OtherContract oc = OtherContract(_Address);
x = oc.getX();
}
调用合约并发送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{ |
转账后,我们可以通过Log
事件和getBalance()
函数观察目标合约ETH
余额的变化。
Call 方式调用
call
是address
类型的低级成员函数,它用来与其他合约交互。它的返回值为(bool, data)
,分别对应call
是否成功以及目标函数的返回值。
call
是solidity
官方推荐的通过触发fallback
或receive
函数发送ETH
的方法。- 不推荐用
call
来调用另一个合约,因为当你调用不安全合约的函数时,你就把主动权交给了它。推荐的方法仍是声明合约变量后调用函数 - 当我们不知道对方合约的源代码或
ABI
时,就没法生成合约变量;这时我们就可以通过call
调用对方合约的函数。
使用方式
1 | 目标合约地址.call(二进制编码); |
其中二进制编码
是利用结构化编码函数abi.encodeWithSignature
获得:
1 | abi.encodeWithSignature("函数签名", 逗号分隔的具体参数); |
其中函数签名
为"函数名(逗号分隔的参数类型)"
,例如:
1 | abi.encodeWithSignature("f(uint256,address)", _x, _addr); |
另外call
在调用合约时可以指定交易发送的ETH
数额和gas
:
1 | 目标合约地址.call{value:发送ETH数额, gas:gas数额}(二进制编码); |
看起来有点复杂,下面我们举个call
应用的例子,首先编写一个目标合约
1 | contract OtherContract { |
这个合约包含一个状态变量x
,一个事件Log
在收到ETH
时触发,三个函数:
getBalance()
: 返回合约ETH
余额setX()
:external payable
函数,可以设置x
的值,并向合约发送ETH
getX()
: 读取x
的值
调用
我们写一个Call
合约来调用目标合约函数。在合约中定义一个Response
事件,输出call
返回的success
和data
,方便我们观察返回值。
调用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
,也就是空。
调用getX函数
下面我们调用getX()
函数,它将返回目标合约_x
的值,类型为uint256
。我们可以利用abi.decode
来解码call
的返回值data
,并读出数值。
1 | function callGetX(address _addr) external returns(uint256){ |
从Response
事件的输出,我们可以看到data
为0x0000000000000000000000000000000000000000000000000000000000000005
。而经过abi.decode
,最终返回值为5
。
调用不存在的函数
如果我们给call
输入的函数不存在于目标合约,那么目标合约的fallback
函数会被触发。
1 | function callNonExist(address _addr) external{ |
上面例子中,我们call
了不存在的foo
函数。call
仍能执行成功,并返回success
,但其实调用的目标合约fallback
函数。
总结
这一讲,我们介绍了如何用call
这一低级函数来调用其他合约。call
不是调用合约的推荐方法,因为不安全。但他能让我们在不知道源代码和ABI
的情况下调用目标合约,很有用。
DelegateCall方式调用
delegatecall
与call
类似,是solidity
中地址类型的低级成员函数。delegate
中是委托/代表的意思,那么delegatecall
委托了什么?
当用户A
通过合约B
来call
合约C
的时候,执行的是合约C
的函数,语境
(Context
,可以理解为包含变量和状态的环境)也是合约C
的:msg.sender
是B
的地址,并且如果函数改变一些状态变量,产生的效果会作用于合约C
的变量上。
合约A | call–> | 合约B | call–> | 合约C |
msg.sender=A | msg.sender=B | |||
msg.value=A给的 | msg.value=B给的 |
而当用户A
通过合约B
来delegatecall
合约C
的时候,执行的是合约C
的函数,但是语境
仍是合约B
的:msg.sender
是A
的地址,并且如果函数改变一些状态变量,产生的效果会作用于合约B
的变量上。
合约A | call–> | 合约B | delegatecall–> | 合约C |
msg.sender=A | msg.sender=A | |||
msg.value=A给的 | msg.value=A给的 |
使用方式
delegatecall
语法和call
类似
1 | 目标合约地址.delegatecall(二进制编码); |
其中二进制编码
利用结构化编码函数abi.encodeWithSignature
获得:
1 | abi.encodeWithSignature("函数签名", 逗号分隔的具体参数); |
其中函数签名
为"函数名(逗号分隔的参数类型)"
,例如:
1 | abi.encodeWithSignature("f(uint256,address)", _x, _addr); |
和call
不一样,delegatecall
在调用合约时可以指定交易发送的gas
,但不能指定发送的ETH
数额。
注意:
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里的状态变量 |
允许结果
我们把合约B
和C
都部署好,然后调用合约C
中的callSetVars
,传入参数为合约B
地址和10
。运行后,合约C
中的状态变量将被修改:num
被改为10
,sender
变为合约B
的地址。
接下来,我们调用合约C
中的delegatecallSetVars
,传入参数为合约B
地址和100
。由于是delegatecall
,语境为合约B
。在运行后,合约B
中的状态变量将被修改:num
被改为100
,sender
变为合约你的钱包地址。
什么情况下会用到DelegateCall
目前delegatecall
主要有两个应用场景:
- 代理合约(
Proxy Contract
):将智能合约的存储合约和逻辑合约分开:代理合约(Proxy Contract
)存储所有相关的变量,并且保存逻辑合约的地址;所有函数存在逻辑合约(Logic Contract
)里,通过delegatecall
执行。当升级时,只需要将代理合约指向新的逻辑合约即可。 - EIP-2535 Diamonds(钻石):钻石是一个支持构建可在生产中扩展的模块化智能合约系统的标准。钻石是具有多个实施合同的代理合同。 更多信息请查看:钻石标准简介。
总结
这一讲我们介绍了solidity
中的另一个低级函数delegatecall
。与call
类似,它可以用来调用其他合约;不同点在于运行的语境,B call C
,语境为C
;而B delegatecall C
,语境为B
。目前delegatecall
最大的应用是代理合约和EIP-2535 Diamonds
(钻石)。