逻辑和存储分离#
我们都知道,智能合约部署到区块链上后合约的代码是无法篡改的,一旦合约出现 bug 项目方很多时候也无计可施
合约通常由存储的变量和逻辑函数组成,因为迁移变量到另一个新合约上开销太大,并且主要升级的就是逻辑函数,因此可以将存储的变量和逻辑函数进行合约层面上的隔离
因此完整的调用链路如下图所示:
- user 与 contract-A 交互,A 中不记录函数逻辑,只存储变量
- contract-A 通过 delegatecall 调用 contract-B,将 B 中的函数逻辑作用于 contract-A
- 调用结束,在 user 视角中只有 contract-A,不感知 contract-B 的存在
基础的可升级合约实现#
参照上面的调用链路,如果将 contract-B 替换为其他合约,即可完成逻辑合约的更替而不影响原来的合约存储
proxy 合约#
- implementation:记录了逻辑合约地址
- 利用 fallback 函数,将函数调用全部通过 delegatecall 转移到逻辑合约上
- 提供 upgrade 函数用于更换逻辑合约地址
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.4;
// 简单的可升级合约,管理员可以通过升级函数更改逻辑合约地址,从而改变合约的逻辑。
contract SimpleUpgrade {
address public implementation; // 逻辑合约地址
address public admin; // admin地址
string public words; // 字符串,可以通过逻辑合约的函数改变
// 构造函数,初始化admin和逻辑合约地址
constructor(address _implementation){
admin = msg.sender;
implementation = _implementation;
}
// fallback函数,将调用委托给逻辑合约
fallback() external payable {
(bool success, bytes memory data) = implementation.delegatecall(msg.data);
}
// 升级函数,改变逻辑合约地址,只能由admin调用
function upgrade(address newImplementation) external {
require(msg.sender == admin);
implementation = newImplementation;
}
}
logic 合约#
- 需要保持和 proxy 合约相同的存储布局(变量类型、变量顺序)
- 逻辑合约中的函数将会将变量的修改作用到 proxy 合约上,因此调用
foo()
函数后将会将 proxy 中的words
变量修改为old
contract Logic1 {
// 状态变量和proxy合约一致,防止插槽冲突
address public implementation;
address public admin;
string public words; // 字符串,可以通过逻辑合约的函数改变
// 改变proxy中状态变量,选择器: 0xc2985578
function foo() public{
words = "old";
}
}
新的逻辑合约:
- 保持一致的存储布局
- 调整函数逻辑,完成逻辑的更新,调用
foo()
函数后将会将 proxy 中的words
变量修改为new
contract Logic2 {
// 状态变量和proxy合约一致,防止插槽冲突
address public implementation;
address public admin;
string public words; // 字符串,可以通过逻辑合约的函数改变
// 改变proxy中状态变量,选择器:0xc2985578
function foo() public{
words = "new";
}
}
合约升级#
admin 账户调用 proxy 合约中的upgrade
方法,传入新的逻辑合约地址,即可完成升级
存在的问题#
在 fallback 中调用 delegatecall 可能会出现 selector 冲突的问题
函数的选择器 selector 就是函数签名的哈希前四个字节,不同的函数是存在冲突的可能性的,如果合约中存在两个选择器冲突的函数时,合约是无法完成编译的,但是 proxy 合约和逻辑合约之间可能存在冲突,在编译环节无法检测
透明代理#
透明代理就是为了解决选择器冲突而导致的升级问题,因此透明代理的思路是将 upgrade 函数的权限和 fallback 中执行 delegatecall 的权限隔离开
- fallback 函数,要求不是 admin 调用
- upgrade 函数,要求必须是 admin 调用
以此避免因函数选择器冲突导致的升级问题
fallback() external payable {
require(msg.sender != admin);
(bool success, bytes memory data) = implementation.delegatecall(msg.data);
}
// 升级函数,改变逻辑合约地址,只能由admin调用
function upgrade(address newImplementation) external {
if (msg.sender != admin) revert();
implementation = newImplementation;
}
通用可升级代理 UUPS#
透明代理的模式每次都需要校验管理员地址,耗费 gas
UUPS 模式将升级的函数放在逻辑合约里,在单合约中可在编译时就避免升级函数的选择器冲突问题
原先的升级函数是在 proxy 合约中,且限制为 admin 权限,现在挪到 logic 合约中,由于 delegatecall 过程中 msg.sender 是不变的,而且存储的 admin 依旧是 proxy 合约中记录的,因此这个校验逻辑放在 logic 合约中一样可行
UUPS 升级合约示例如下:在逻辑合约中记录 implementation 地址,并作用到 proxy 合约中
proxy 合约#
只有存储的变量、构造函数和 fallback,去掉了升级函数
contract UUPSProxy {
address public implementation; // 逻辑合约地址
address public admin; // admin地址
string public words; // 字符串,可以通过逻辑合约的函数改变
// 构造函数,初始化admin和逻辑合约地址
constructor(address _implementation){
admin = msg.sender;
implementation = _implementation;
}
// fallback函数,将调用委托给逻辑合约
fallback() external payable {
(bool success, bytes memory data) = implementation.delegatecall(msg.data);
}
}
logic 合约#
增加了升级函数
// UUPS逻辑合约(升级函数写在逻辑合约内)
contract UUPS1{
// 状态变量和proxy合约一致,防止插槽冲突
address public implementation;
address public admin;
string public words; // 字符串,可以通过逻辑合约的函数改变
// 改变proxy中状态变量,选择器: 0xc2985578
function foo() public{
words = "old";
}
// 升级函数,改变逻辑合约地址,只能由admin调用。选择器:0x0900f010
// UUPS中,逻辑函数中必须包含升级函数,不然就不能再升级了。
function upgrade(address newImplementation) external {
require(msg.sender == admin);
implementation = newImplementation;
}
}
// 新的UUPS逻辑合约
contract UUPS2{
// 状态变量和proxy合约一致,防止插槽冲突
address public implementation;
address public admin;
string public words; // 字符串,可以通过逻辑合约的函数改变
// 改变proxy中状态变量,选择器: 0xc2985578
function foo() public{
words = "new";
}
// 升级函数,改变逻辑合约地址,只能由admin调用。选择器:0x0900f010
// UUPS中,逻辑函数中必须包含升级函数,不然就不能再升级了。
function upgrade(address newImplementation) external {
require(msg.sender == admin);
implementation = newImplementation;
}
}