抱歉,您的浏览器无法访问本站
本页面需要浏览器支持(启用)JavaScript
了解详情 >

1. 前言

solidity智能合约部署到链上之后,代码是不能再修改的,这样有好也有坏。

  • 好:用户可以知道代码的运行逻辑,不用担心代码被人私自篡改从而执行恶意操作;
  • 坏:一旦发现之前部署的智能合约出现bug,hacker可以利用bug执行恶意操作,而本着合约不可篡改的特性,合约不能进行修复和升级,只能通过重新部署新的合约,而这样一来用户的数据将会被清空,若是要实现数据迁移则付出的gas成本会很高。

正是基于如上痛点,提出了可升级的智能合约的理念,该理念的目的是:实现智能合约在部署之后,还可以进行合约升级。

当下有两种主流的合约升级方式:

  • 数据逻辑分离
  • 代理模式

今天要学习的是采用数据逻辑分离模式实现合约升级。

2. 数据逻辑分离模式

将数据和逻辑保存在不同的合约中,逻辑合约负责调用和操作数据合约。这种方式也被称为 永久数据存储模式。

2.1 如何理解

如何理解这种模式?

本质上就是:将逻辑合约和存储数据合约分离成俩个合约,逻辑合约负责调用存储数据合约。

image-20240512125107849

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

image-20240512124452052

2.2 代码复现

对于合约的升级一定是有严格的访问控制的,升级操作需要添加严格的控制权,若是anyone都可以执行升级操作,那么合约很容易报废,待会我会复现。

本次复现的逻辑是:storage contract合约所有者将合约所有者权移交给V1V1负责操作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

image-20240512165157976

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

image-20240512165315281

假如此时项目方发现这个明显的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票

image-20240512165921289

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

image-20240512170115964

2.3 优缺点

优点:

  • 容易理解和上手
  • 消除了合约更新后数据的迁移问题

缺点:

  • 数据变量的访问模式很困难,比如需要在数据合约中添加一个变量而完成某种的功能,这几乎不可能实现,除非重新部署一个数据合约,那么这还是需要大量的gas成本
  • 增加了复杂的所有权授权模式

3. Metamorphic Contracts

译文:变形合约。

忽略数据不变性,这也算是一种可升级合约的方式,这是采用create2的操作码实现的。众所周知,在以太坊中部署合约可以获取到一个地址,在代码中操作的也是合约地址,那么理论上,一个合约地址可以看成是一个合约,合约地址不变,那么就可以间接看作是合约没有改变。而通过create2操作码进行升级的理念便是,在同一个合约地址上部署上不同的逻辑,合约的执行逻辑主要是依靠runtimeCode,这要每次部署合约时传入的runtimeCode不一样就能实现。

3.1 复现

那么如何重复部署同一个合约地址呢?

这需要用到selfdestructcreate2盐。selfdestruct负责销毁合约,只有销毁了合约才能再部署同一合约地址,此外,自毁功能是必须要提供的,否则该升级模式将会失败。而且自毁功能需要有访问控制。

1
2
// create2部署合约的原理
0xFF + address(deployer) + salt+ keccak256(creationCode)

通过create2部署合约到同一个地址,0xFFaddress(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))
}
}
}

这是目标合约,通过TargetcreationCode不变,从而达成上述要求。

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()函数,结果如下:

image-20240512211340595

升级逻辑:

  • 调用 Test合约的kill函数,将target合约销毁。

image-20240512211547174

  • 通过GetRuntimeCode合约的getV2()函数获取,V2的runtimeCode: run_v2

  • run_v2作为参数,调用deploy()函数,部署Target合约,

  • 随后继续调用 test()函数,结果如下:

image-20240512211707978

此时,你会发现结果还是原来的3,这是为什么呢???

这是因为在EIP-4756中提及过,将移除selfdestruct这一操作码,这也是该种合约升级模式的弊端之一。

看到我使用的编译版本:

image-20240512212024234

Cancun硬分叉之后,这个自毁功能是被移除了的,那么该如何复现呢?

答案:换一个网络,以及换低版本编译器。我换成如下:

image-20240512214140980

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

image-20240512212440328

复现完毕。当然实际案例需要添加访问控制。

3.2 谈谈该模式的好与坏

  • 好:
    • 不需要使用delegatecall代理,效率高不需要转发调度。
    • 不需要使用initialize()代替constructor()
  • 坏:
    • selfdestruct在接下来的网络升级中会可能会被移除。
    • selfdestruct会将合约数据抹除。
    • 需要的成本较高,升级一次需要执行一次selfdestruct和部署一次合约。

评论



政策 · 统计 | 本站使用 Volantis 主题设计