1. 前言
solidity智能合约部署到链上之后,代码是不能再修改的,这样有好也有坏。
- 好:用户可以知道代码的运行逻辑,不用担心代码被人私自篡改从而执行恶意操作;
- 坏:一旦发现之前部署的智能合约出现bug,hacker可以利用bug执行恶意操作,而本着合约不可篡改的特性,合约不能进行修复和升级,只能通过重新部署新的合约,而这样一来用户的数据将会被清空,若是要实现数据迁移则付出的gas成本会很高。
正是基于如上痛点,提出了可升级的智能合约的理念,该理念的目的是:实现智能合约在部署之后,还可以进行合约升级。
当下有两种主流的合约升级方式:
今天要学习的是采用数据逻辑分离模式
实现合约升级。
2. 数据逻辑分离模式
将数据和逻辑保存在不同的合约中,逻辑合约负责调用和操作数据合约。这种方式也被称为 永久数据存储
模式。
2.1 如何理解
如何理解这种模式?
本质上就是:将逻辑合约和存储数据合约分离成俩个合约,逻辑合约负责调用存储数据合约。

执行逻辑:逻辑合约V1
通过call
的方式去调用stroage contract
,当合约需要升级的时候,将V1
替换成V2
然后用户通过V2
版本的逻辑合约去调用storage contract
。

2.2 代码复现
对于合约的升级一定是有严格的访问控制的,升级操作需要添加严格的控制权,若是anyone都可以执行升级操作,那么合约很容易报废,待会我会复现。
本次复现的逻辑是:storage contract
合约所有者将合约所有者权移交给V1
,V1
负责操作storage contract
,同时它还具有移交storage contract
所有权的功能。
代码写得很粗糙,旨在复现逻辑过程 :)。
本次复现使用了三个合约:
- StorageTickets:负责存储数据,这个合约始终是不变的,负责记录票数和账户是否投票的情况
- V1:逻辑合约的V1版本,具有投票和查询票数功能,还具有转移
StorageTickts
合约所有权的功能(具有访问控制)。
- V2:逻辑合约的V2版本,具有投票和查询票数功能,但是每个用户只能投一次,还具有转移
StorageTickts
合约所有权的功能(具有访问控制)。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64
| // SPDX-License-Identifier: MIT pragma solidity ^0.8.20;
import "https://github.com/OpenZeppelin/openzeppelin-contracts/blob/master/contracts/access/Ownable.sol";
contract StorageTickets is Ownable {
mapping(bytes32 => uint) VoteAmounts; // solt 1 mapping(address => bool) BooleanVote; // slot 2
constructor() Ownable(msg.sender) {}
function getVoteAmounts(bytes32 record) public view returns (uint) { return VoteAmounts[record]; }
function setVoteAmounts(bytes32 record, uint value) public { VoteAmounts[record] = value; }
function getBooleanVote(address account) public view returns (bool){ return BooleanVote[account]; }
function setBooleanVote(address account, bool value) public { BooleanVote[account] = value; }
}
// V1 版本 contract V1 {
address owner; StorageTickets storageTickets;
constructor(address _storageTickets) { owner = msg.sender; storageTickets = StorageTickets(_storageTickets); }
modifier onlyOwner() { require(msg.sender == owner); _; }
// 查询 keccak256(abi.encodePacked("votes"))获取的票数 function getNumberOfVotes() public view returns (uint256) { bytes32 votes = keccak256(abi.encodePacked("votes")); return storageTickets.getVoteAmounts(votes); }
// 投票 function vote() public { bytes32 votes = keccak256(abi.encodePacked("votes")); storageTickets.setVoteAmounts(votes, storageTickets.getVoteAmounts(votes) + 1); }
function transferOwner(address _newOwner) public onlyOwner { storageTickets.transferOwnership(_newOwner); } }
|
- 先部署
StorageTickets.sol
- 将部署生成的地址作为
V1
构造器的参数,部署V1
StorageTickets
的所有者将所有权通过transferOwnerShip()
函数,移交所有权给V1

用户0x4B20993Bc481177ec7E8f571ceCaE8A9e22C02db
连续5次点击V1的vote函数,可以看到票数为5。

假如此时项目方发现这个明显的bug,没有限制每个用户的投票次数,这可能会导致有人恶意刷单,项目方明确规定每人只能投一次票,那么很明显合约需要升级,升级成V2版本。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34
| // V2 版本 contract V2 {
address owner; StorageTickets storageTickets;
constructor(address _storageTickets) { owner = msg.sender; storageTickets = StorageTickets(_storageTickets); }
modifier onlyOwner() { require(msg.sender == owner); _; }
// 查询 keccak256(abi.encodePacked("votes"))获取的票数 function getNumberOfVotes() public view returns (uint256) { bytes32 votes = keccak256(abi.encodePacked("votes")); return storageTickets.getVoteAmounts(votes); }
// 投票 function vote() public { require(storageTickets.getBooleanVote(msg.sender) == false, "Fail, you have already voted:)"); storageTickets.setBooleanVote(msg.sender, true); bytes32 votes = keccak256(abi.encodePacked("votes")); storageTickets.setVoteAmounts(votes, storageTickets.getVoteAmounts(votes) + 1); }
function transferOwner(address _newOwner) public onlyOwner { storageTickets.transferOwnership(_newOwner); } }
|
- 还是传入原始的
storageTickets
合约地址部署V2
- V1合约的所有者移交所有权给V2
此时keacck256("vote")
的票数还是5票

用户0x4B20993Bc481177ec7E8f571ceCaE8A9e22C02db
还想像之前那样不断的投票,当他第二次进行投票时,他的操作被revert()
了。

2.3 优缺点
优点:
缺点:
- 数据变量的访问模式很困难,比如需要在数据合约中添加一个变量而完成某种的功能,这几乎不可能实现,除非重新部署一个数据合约,那么这还是需要大量的gas成本
- 增加了复杂的所有权授权模式
译文:变形合约。
忽略数据不变性,这也算是一种可升级合约的方式,这是采用create2
的操作码实现的。众所周知,在以太坊中部署合约可以获取到一个地址,在代码中操作的也是合约地址,那么理论上,一个合约地址可以看成是一个合约,合约地址不变,那么就可以间接看作是合约没有改变。而通过create2
操作码进行升级的理念便是,在同一个合约地址上部署上不同的逻辑,合约的执行逻辑主要是依靠runtimeCode
,这要每次部署合约时传入的runtimeCode
不一样就能实现。
3.1 复现
那么如何重复部署同一个合约地址呢?
这需要用到selfdestruct
和create2
盐。selfdestruct
负责销毁合约,只有销毁了合约才能再部署同一合约地址,此外,自毁功能是必须要提供的,否则该升级模式将会失败。而且自毁功能需要有访问控制。
1 2
| // create2部署合约的原理 0xFF + address(deployer) + salt+ keccak256(creationCode)
|
通过create2部署合约到同一个地址,0xFF
、address(deployer)
和salt
容易保证不变,但是要替换原合约的逻辑,则必须要更改合约的内容,这样一来creationCode
不就会发生改变了吗,有什么办法能保证creationCode
不变,而runtimeCode
改变?
答案:有的。可以通过solidity的汇编语言,返回函数的runtimecode
,而这个runtimeCode
从构造器中传入,直接看代码。
1 2 3 4 5 6 7 8 9 10 11
| contract Target {
constructor() {
Factory factory = Factory(msg.sender); bytes memory runtimeCode = factory.runtimeCode(); assembly { return(add(runtimeCode, 0x20), mload(runtimeCode)) } } }
|
这是目标合约,通过Target
的creationCode
不变,从而达成上述要求。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25
| contract Factory {
address public owner; bytes public runtimeCode;
modifier onlyOwner() { require(msg.sender == owner); _; }
function deploy(bytes memory _runtimeCode) external onlyOwner returns(address target) { runtimeCode = _runtimeCode; bytes memory creationCode = type(Target).creationCode;
assembly { target := create2( 0, // msg.value add(creationCode, 0x20), // the start of data mload(creationCode), // creationCode.length 0x00 // salt ) } } }
|
这是工厂合约负责部署目标合约,部署功能需要有访问控制,预防恶意操作。
复现:
测试合约:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19
| contract Test {
address target;
constructor(address _target) { target = _target; }
function test() public returns(uint256) { (bool ok, bytes memory rtd) = target.call(abi.encodeWithSignature("cal(uint256,uint256)", 1, 2)); require(ok, "call fail:)"); return abi.decode(rtd, (uint256)); }
function kill() public { (bool ok, bytes memory rtd) = target.call(abi.encodeWithSignature("kill()")); require(ok, "kill fail(:"); } }
|
辅助合约,获取V1和V2的runtimeCode
:
1 2 3 4 5 6 7 8 9 10 11
| contract GetRuntimeCode {
function getV1() public pure returns (bytes memory){ return type(Logic_V1).runtimeCode; }
function getV2() public pure returns (bytes memory){ return type(Logic_V2).runtimeCode; }
}
|
逻辑合约V1,计算方式为加法运算:
1 2 3 4 5 6 7 8 9 10
| contract Logic_V1 { function cal(uint256 n1, uint256 n2) external pure returns (uint256) { return n1 + n2; }
function kill() public { selfdestruct(payable(msg.sender)); } }
|
逻辑合约V2,计算方式为乘法运算:
1 2 3 4 5 6 7 8 9 10
| contract Logic_V2 { function cal(uint256 n1, uint256 n2) external pure returns (uint256) { return n1 * n2; } function kill() public { selfdestruct(payable(tx.origin)); } }
|
执行逻辑:
部署GetRuntimeCode
合约
部署Factory
合约
通过GetRuntimeCode
合约的getV1()
函数获取,V1的runtimeCode
: run_v1
。
将run_v1
作为参数,调用deploy()
函数,部署Target
合约,
地址为:0x0c1720ee8283EB0D46170ba774098Ae648C701c1
。
将该地址作为参数,部署Test
合约,并调用 test()
函数,结果如下:

升级逻辑:
- 调用
Test
合约的kill
函数,将target
合约销毁。


此时,你会发现结果还是原来的3
,这是为什么呢???
这是因为在EIP-4756
中提及过,将移除selfdestruct
这一操作码,这也是该种合约升级模式的弊端之一。
看到我使用的编译版本:

在Cancun
硬分叉之后,这个自毁功能是被移除了的,那么该如何复现呢?
答案:换一个网络,以及换低版本编译器。我换成如下:

再次重复如上步骤,输出结果为:2
。

复现完毕。当然实际案例需要添加访问控制。
3.2 谈谈该模式的好与坏
- 好:
- 不需要使用
delegatecall
代理,效率高不需要转发调度。
- 不需要使用
initialize()
代替constructor()
。
- 坏:
selfdestruct
在接下来的网络升级中会可能会被移除。
selfdestruct
会将合约数据抹除。
- 需要的成本较高,升级一次需要执行一次
selfdestruct
和部署一次合约。