智能合约一旦部署,就是不可改变的。所以一旦你的合约代码出现了bug
或者去添加一些新功能时,也不能去做出修改,转而只能去:
- 重新部署一个新的合约
- 手动将数据从旧合约迁移到新合约
- 更新所有与旧合约交互的合约,包括使用新合约的地址等
- 通知并说服社区使用新合约地址
既然升级合约如此麻烦,那么就需要一个统一的解决方案。可以让我们去改变合约代码,同时保留数据。
代理模式
代理模式将合约数据和逻辑分开,分别保存在不同合约中:
- 代理合约:与用户进行交互的智能合约,保存着数据。它是一个EIP1967 标准代理合约。
- 实现合约:代理合约所代理的合约,提供功能和逻辑。
代理合约将实现合约的地址存储为一个状态变量。用户并不直接向实现合约发送调用。相反,所有的调用都要经过代理合约,经过代理将调用委托给实现合约,并把从实现合约收到的任何数据返回给调用者,或者对错误进行回退。
代理合约通过 delegatecall
函数去调用实现合约。基于delegatecall
的特性,实现合约的状态变量是由代理合约进行存储的。
如下所示:
contract Robo {
uint public count;
function setCount(uint _count) public {
count = _count;
}
}
contract RoboProxy {
function _delegate(address implementation) internal virtual {
// 代理方法: delegatecall 执行实现合约方法
}
function _implementation() internal view virtual returns (address) {
// 返回实现合约 Robo 的地址
}
fallback() external {
_delegate(_implementation());
}
}
当需要调用 Robo
合约的 setCount
方法时,代理模式中我们并不与该合约交互,而是通过代理合约进行调用。即调用 RoboProxy
的 setCount
方法,但 RoboProxy
并没有 setCount
方法。转而会去执行其 fallback
函数(调用不存在的合约方法时会触发 fallback
函数)。fallback
函数内部会去执行 _delegate
方法。
_delegate
方法也就是真正的代理方法,其内部会通过 delegatecall
执行 Robo
合约的 setCount
方法。
基于 delegatecall
的特性,即使 Robo
合约定义了一个变量 count
,其值也不会由 Robo
合约存储。而是由 RoboProxy
合约存储该值。
到这里你可能会困惑,RoboProxy
并没有定义变量 count
,它是如何存储的呢。这其实涉及到了 EVM
的存储布局。
其实每个在 EVM
中运行的智能合约的状态变量的值都在链上永久地存储着,这些值就存储在存储槽中。每一个合约,都有 个存储槽,每个存储槽的大小是 32 个字节。但这么大的存储槽并不是全部存在的,而是用到时才会真正的占据空间。
----------------------------------
| 0 | # slot 0
----------------------------------
| 1 | # slot 1
----------------------------------
| 2 | # slot 2
----------------------------------
| ... | # ...
----------------------------------
| ... | # 每个插槽 32 字节
----------------------------------
| ... | # ...
----------------------------------
| 2^256-1 | # slot 2^256-1
所以当执行代理方法的时候,就会将count
变量的值存储的代理合约的存储槽中。
正是由于这种特殊的存储结构, 所以很容易出现一些潜在的存储碰撞问题。
存储碰撞
代理合约和实现合约之间的存储碰撞
|Proxy |Implementation |
|------------------------|---------------|
|address implementation |address var1 | <- 存储碰撞
| |mapping var2 |
| |uint256 var3 |
| |... |
由于变量 var1
和 implementation
都是合约的第一个变量,也就意味着值都存储在第一个存储槽 slot 0
中。而且具有相同的类型,意味着占用相同的存储空间。当通过代理方法 delegatecall
对 var1
赋值时,实际上会将值写入到代理合约的存储空间上,写入的位置正好是 implementation
变量所占据的空间。所以 implementation
变量的值就是所设置的 var1
的值。
为了解决代理合约和实现合约之间的存储碰撞问题,使用随机存储槽,也就是在巨大的存储空间中,随机选择一个存储槽用来存储 implementation
变量。
|Proxy |Implementation |
|------------------------|---------------|
| .. |address var1 |
| .. |mapping var2 |
| .. |uint256 var3 |
| .. | .. |
| .. | .. |
|address implementation | .. | <- 随机槽
根据EIP-1967,存储实现合约地址的存储槽的位置在:
bytes32 private constant implementationPosition = bytes32(uint256(
keccak256('eip1967.proxy.implementation')) - 1
));
// 值为 0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc
每次实现地址需要被访问/修改时,都会读/写这个存储槽。
不同版本的实现合约之间的存储碰撞
当升级到一个新的实现合约时,如果一个新的状态变量被添加到实现合约中,应该被追加到存储布局中。而不是随意的插入到原先的存储布局中。
|ImplementationV1 |ImplementationV2|
|-----------------|----------------|
|address foo |address baz | <- 存储碰撞
|mapping bar |address foo |
| |mapping bar |
| |... |
由于baz
的插入,导致了原先 foo
的值错乱。
正确做法是不改变原先的变量位置而是去追加变量。
|ImplementationV1 |ImplementationV2|
|-----------------|----------------|
|address foo |address foo |
|mapping bar |mapping bar |
| |address baz | <- 追加
| |... |
合约的初始化
由于代理合约存储着所有的数据,所以数据初始化的代码也需要被执行。但初始化代码往往是写在实现合约的构造函数里的,并且构造函数只在部署的时候执行一次,这也就导致代理合约没法执行初始化的代码,数据没有得到初始化。
OpenZeppelin
的解决办法是将初始化的代码由实现合约的构造函数转移到 initialize
函数中
import "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol";
contract RoboToken is Initializable {
// `initializer` modifier 确保该方法中只执行一次
function initialize(
address arg1,
uint256 arg2,
bytes memory arg3
) public payable initializer {
// 初始化代码...
}
}
之后由代理合约执行 initialize
函数
代理方法实现
代理合约中执行实现合约中的方法是通过 delegatecall
执行的。
_implementation.delegatecall(abi.encodeWithSignature("setValue()", 10));
或者
_implementation.delegatecall(abi.encodeWithSelector(Robo.setValue.selector, 10));
然而这两种方式都需要知道实现合约具体的方法名,没法做到通用。
所以就有了内联汇编这种调用方式,实现如下
function _delegate(address implementation) internal virtual {
assembly {
// 将 calldata 拷贝到内存里
// calldata的前4个字节是函数selector, 紧接着的是函数参数。通过函数selector 就能匹配到要调用的函数
calldatacopy(0, 0, calldatasize())
// delegatecall 调用 implementation 的方法
let result := delegatecall(gas(), implementation, 0, calldatasize(), 0, 0)
// 拷贝返回数据
returndatacopy(0, 0, returndatasize())
// 返回 0 表示出错
switch result
case 0 {
revert(0, returndatasize())
}
default {
return(0, returndatasize())
}
}
}
代理合约的实现用到了一些内联汇编函数
calldatacopy(t, f, s)
: 将msg.data
从位置f
复制calldatasize()
长度的数据到内存位置t
delegatecall(g, a, in ,insize, out, outsize)
:调用地址a
,输入数据为内存位置[in...in+insize]
间的数据,并将结果输出到内存[out...out+outsize]
位置上returndatacopy(t, f, s)
:将输出数据从位置f
复制returndatasize()
长度的到内存位置t
revert(p, s)
:终止执行并回滚, 返回内存位置[p...p+s]
间的数据return(p, s)
:正常返回结果, 返回内存位置[p...p+s]
间的数据