可升级合约-非代理模式

  • BY_DLIFE
  • 更新于 2024-05-12 21:42
  • 阅读 587

使用非代理模式实现合约升级

1. 前言

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

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

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

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

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

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

2. 数据逻辑分离模式

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

2.1 如何理解

如何理解这种模式?

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

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

image.png

2.2 代码复现

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

本次复现的逻辑是:storage contract合约所有者将合约所有者权移交给V1V1负责操作storage contract,同时它还具有移交storage contract所有权的功能。

代码写得很粗糙,旨在复现逻辑过程 :)。

本次复现使用了三个合约:

  • StorageTickets:负责存储数据,这个合约始终是不变的,负责记录票数和账户是否投票的情况
  • V1:逻辑合约的V1版本,具有投票和查询票数功能,还具有转移 StorageTickts合约所有权的功能(具有访问控制)。
  • V2:逻辑合约的V2版本,具有投票和查询票数功能,但是每个用户只能投一次,还具有转移 StorageTickts合约所有权的功能(具有访问控制)。
// 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.png

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

image.png

假如此时项目方发现这个明显的bug,没有限制每个用户的投票次数,这可能会导致有人恶意刷单,项目方明确规定每人只能投一次票,那么很明显合约需要升级,升级成V2版本。

// 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.png

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

image.png

2.3 优缺点

优点:

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

缺点:

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

3. Metamorphic Contracts

译文:变形合约。

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

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

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

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

通过create2部署合约到同一个地址,0xFFaddress(deployer)salt容易保证不变,但是要替换原合约的逻辑,则必须要更改合约的内容,这样一来creationCode不就会发生改变了吗,有什么办法能保证creationCode不变,而runtimeCode改变?

答案:有的。可以通过solidity的汇编语言,返回函数的runtimecode,而这个runtimeCode从构造器中传入,直接看代码。

contract Target {

    constructor() {

        Factory factory = Factory(msg.sender);
        bytes memory runtimeCode = factory.runtimeCode();
        assembly {
            return(add(runtimeCode, 0x20), mload(runtimeCode))
        }
    }
}

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

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 
            )
        }
    }
}

这是工厂合约负责部署目标合约,部署功能需要有访问控制,预防恶意操作

复现:

测试合约:

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

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,计算方式为加法运算:

contract Logic_V1 {

    function cal(uint256 n1, uint256 n2) external pure returns (uint256) {
        return n1 + n2;
    }

    function kill() public {
        selfdestruct(payable(msg.sender));
    }
}

逻辑合约V2,计算方式为乘法运算:

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.png

升级逻辑:

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

image.png

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

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

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

image.png

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

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

看到我使用的编译版本:

image.png

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

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

image.png

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

image.png

复现完毕。

谈谈这个模式的好与坏

  • 好:
    • 不需要使用delegatecall代理,效率高不需要转发调度。
    • 不需要使用initialize()代替constructor()
  • 坏:
    • selfdestruct在接下来的网络升级中会可能会被移除。
    • selfdestruct会将合约数据抹除。
    • 需要的成本较高,升级一次需要执行一次selfdestruct和部署一次合约。
点赞 6
收藏 4
分享
本文参与登链社区写作激励计划 ,好文好收益,欢迎正在阅读的你也加入。

5 条评论

请先 登录 后评论
BY_DLIFE
BY_DLIFE
0x994d...4240
立志成为一名智能合约安全审计师。文章都是我的个人理解,如果有不对的地方欢迎在评论区指出来。