我们通过delegatecall这一方式实现了合约升级,同时通过对合约状态存储的理解明白了为什么会存在存储冲突并采用继承的方式进行规避,最后我们的升级是建立在代理合约的fallback函数之上的,这个函数没有返回值,我们通过Solidity Assembly的方式进行数据返回。合约升级在技术上是不可或缺的手段,较好的使用可以帮助我们修复合约漏洞,对合约进行功能迭代,但如果使用不当则会造成较坏的影响,所以合约升级应该配合有效的升级治理方案。
智能合约的特点之一就是部署到链上之后不能修改,这一机制使得合约的交互方都可以信任合约。但也带来了一系列的问题,其一如果已部署的合约发现漏洞,无法修复,其二如果已部署的合约要进行功能升级,很难对使用方无感知。所以我们希望能够想一种办法能够升级合约,解决上述问题。当然合约升级不仅仅是个技术问题,也是治理问题,因为合约如果可以被随意修改那么信任的基石将消失,所以需要有一系列的升级治理方案来对升级合约进行约束。本文主要讨论合约升级的技术实现,升级治理暂不讨论。
先看下我们希望达成的目标。
这是我们的直接目标,之前调用合约V1的调用者全部改为调用合约V2,但这样一来所有调用者都需要更改,只要有1个调用者漏改,那么他就会调用失败。有没有一种方案不需要调用者感知呢?计算机设计中"加一层"的思想又得到了充分应用,我们增加代理合约,调用方只对接代理合约,合约升级代理合约内部解决。
现在我们的目标就是思考如何实现核心的代理合约。
升级合约中应该有2个方法:
代理方法需要如何调用实现合约内的方法呢?我们需要先来研究一下合约之间方法调用有哪几种方式然后选择适合这个场景的来使用。
直接调用 这种方式我们需要显示指定调用的方式,就像正常的函数调用一样,需要将合约地址传入构造出被调用合约。
/**
调用者合约
**/
contract A {
function x(address addr) public {
B b = B(addr);
b.y();
}
}
/**
被调用者合约
**/
contract B {
uint public t;
function y() public {
t = t + 1;
}
}
call调用 这种调用方式是使用ABI来对方法进行调用(对ABI不了解的小伙伴可以自行查阅,简单理解为将方法编码成二进制表示)
/**
调用者合约
**/
contract A {
function x(address addr) public {
//获得方法对应的ABI,可以由外部传入
bytes memory method = abi.encodeWithSignature("y()");
(bool success, ) = addr.call(method);
}
}
delegatecall调用 另外一种使用ABI方式来对方法进行调用,不过和call不同的是delegatecall调用不会切换上下文到目标合约中,下面会详细介绍。
/**
调用者合约
**/
contract A {
function x(address addr) public {
//获得方法对应的bytes,可以由外部传入
bytes memory method = abi.encodeWithSignature("y()");
(bool success, ) = addr.delegatecall(method);
}
}
在这个例子中使用delegatecall,我们会发现在调用完x()方法后,B的t状态并没有变化。delegatecall是在本合约的上下文中去执行目标合约的方法逻辑,所以当方法执行寻找状态t时,也是在A合约去寻找而不是在B合约去寻找。
3种调用方式之间的对比
调用方式 | 被调用方法执行错误影响 | 抽象程度 | 调用后上下文切换 |
---|---|---|---|
直接调用 | 调用方法也会对应错误回滚 | 低,非常具象,调用方法需要硬编码到合约中 | 调用后切换到被调用合约上下文 |
call | 调用方法不会受到影响 | 高,ABI方式,可以外部传入调用 | 调用后切换到被调用合约上下文 |
delegatecall | 调用方法不会受到影响 | 高,ABI方式,可以外部传入调用 | 调用后依旧在调用合约上下文 |
那么合约升级需要选择哪种调用方式呢?我们从2个方面角度考虑:
从这2方面考虑,我们使用delegatecall来作为代理合约调用实现合约的方式。
我们来看一版最简实现代码
pragma solidity ^0.8.0;
/**
调用者合约
**/
contract Proxy {
address public impl;
event log(bytes);
fallback () external payable{
//msg.data即是被调用方法的ABI表述
(bool success, ) = addr.delegatecall(msg.data);
emit log(res);
}
//升级方法,如果需要升级调用该方法设置升级后的合约地址即可
function setImpl(address addr) public{
impl = addr;
}
}
pragma solidity ^0.8.0;
/**
实现合约 V1
**/
contract ImplV1 {
uint public t;
function addT() public payable {
t = t + 1;
}
function getT() public returns (uint res){
return t;
}
}
pragma solidity ^0.8.0;
/**
实现合约 V2
**/
contract ImplV2 {
uint public t;
function addT() public payable {
t = t + 2;
}
function getT() public returns (uint res){
return t;
}
}
在这里我们使用了fallback()函数作为代理合约入口,fallback函数的特点是如果合约中找不到对应函数则会进入fallback()函数。所以调用者只需要按照ABI规范正常调用即可。 调用函数时如何寻找该函数的ABI呢?如果使用remix按照下图方式即可找到
大家可以在remix上部署运行一下这3个合约,会发现1个问题,通过代理合约调用完addT()方法后,再调用getT()并未获得预期结果。这其实是因为代理合约和实现合约之间发生了存储冲突。
由于为了不让存储数据丢失,我们将所有状态的存储放在代理合约中。但solidity的数据存储并没有使用标识符来进行引用而是按照数字,这个数字就是按照合约声明的顺序从0开始递增。
不仅会存在代理合约和实现合约的冲突,还会出现实现合约的不同版本之间的冲突。
如何解决存储冲突的问题,这里介绍一种个人比较喜欢的方案: 存储位置预留,如上文所述合约中的存储是按照声明顺序作为编号进行索引寻找的。发生冲突的原因是因为不同合约索引相同,如何能够让不同合约之前的状态的索引编号保证不同呢?使用继承,合约之间进行继承,其中的状态变量也就有了确定的顺序,不会存在重合的问题。
再来看下解决完存储冲突后的合约升级代码
pragma solidity ^0.8.0;
//将存储单独拆1个合约(不需要部署),方便进行继承
contract ProxyStore {
address public impl;
event log(bytes);
}
pragma solidity ^0.8.0;
import "./ProxyStore.sol";
contract ImplV1Store is ProxyStore {
uint public t;
}
pragma solidity ^0.8.0;
import "./ImplV1Store.sol";
//因为本例中V2暂未新增状态
contract ImplV2Store is ImplV1Store {}
pragma solidity ^0.8.0;
import "./ProxyStore.sol";
/**
调用者合约,继承自己的存储合约
**/
contract Proxy is ProxyStore{
fallback () external payable{
(bool success, bytes memory res) = impl.delegatecall(msg.data);
emit log(res);
}
function setImpl(address addr) public{
impl = addr;
}
}
pragma solidity ^0.8.0;
import "./ImplV1Store.sol";
/**
实现合约 V1,继承自己的存储合约
**/
contract ImplV1 is ImplV1Store{
//
function addT() public payable {
t = t + 1;
}
function getT() public returns (uint res){
return t;
}
}
pragma solidity ^0.8.0;
import "./ImplV2Store.sol";
/**
实现合约 V2,继承自己的存储合约
**/
contract ImplV2 is ImplV2Store{
function addT() public payable {
t = t + 2;
}
function getT() public returns (uint res){
return t;
}
}
至此部分合约函数就拥有了升级能力,为什么是部分呢?fallback()函数是没有返回值的,当调用者调用1个原本有返回值的函数时,由于经过了代理合约的fallback()函数,所以无法将结果返回。我们有没有办法为fallback()也增加返回值呢?
上层无法实现,但汇编操作可以为我们让我们实现这一能力。Solidity提供了Solidity Assembly让我们更友好地进行汇编操作,并且可以和Solidity语言混合使用。本文主题是合约升级,这部分我们不过多介绍。让我们来看看如何使用底层汇编为fallback()增加返回值。
看下实现代码,下面我们逐一介绍这里涉及到的指令
pragma solidity ^0.8.0;
import "./ProxyStore.sol";
/**
调用者合约
**/
contract Proxy is ProxyStore{
fallback () external payable{
(bool success, bytes memory res) = impl.delegatecall(msg.data);
emit log(res);
assembly {
//分配空闲区域指针
let ptr := mload(0x40)
//将返回值从返回缓冲去copy到指针所指位置
returndatacopy(ptr, 0, returndatasize())
//根据是否调用成功决定是返回数据还是直接revert整个函数
switch success
case 0 { revert(ptr, returndatasize()) }
default { return(ptr, returndatasize()) }
}
}
function setImpl(address addr) public{
impl = addr;
}
}
mload(0x40) //0x40地址会保存下一个可用地址 returndatacopy // 调用call或者delegatecall后其结果数据都会存储在返回值缓冲区中,returndatacopy方法可以将缓冲区的内容copy到指定地址 returndatasize // 返回值缓冲区数据的大小 switch //这里不使用if的原因是 Solidity Assembly的if不支持else return //返回 revert //回滚
其完整过程: 获得可用地址的指针,将返回缓冲区的数据copy到可用地址,根据调用是否成功,如果成功则返回可用地址内的数据,如果失败则进行回滚。
我们通过delegatecall这一方式实现了合约升级,同时通过对合约状态存储的理解明白了为什么会存在存储冲突并采用继承的方式进行规避,最后我们的升级是建立在代理合约的fallback函数之上的,这个函数没有返回值,我们通过Solidity Assembly的方式进行数据返回。合约升级在技术上是不可或缺的手段,较好的使用可以帮助我们修复合约漏洞,对合约进行功能迭代,但如果使用不当则会造成较坏的影响,所以合约升级应该配合有效的升级治理方案。
如果觉得我的文章对您有用,请随意打赏。你的支持将鼓励我继续创作!