可升级合约

本章讲解如何实现可升级合约。

视频Bilibili  |  Youtube
官网binschool.app
推特@BinSchool    DiscordBinDAO   微信:bkra50 

区块链的核心特性之一就是数据不可篡改。智能合约一旦部署在区块链上,它的执行代码就无法再修改了。

已经上链的智能合约如果存在缺陷,或者需要添加新功能,就需要重新部署一个新的合约,废弃原有合约。

使用这种方式来升级合约,运营方需要通知所有用户,将他们使用的旧合约地址改为新合约地址,升级成本非常昂贵。

为了解决以上问题,就产生了“可升级合约”的方案。可升级合约能够实现对智能合约的平滑升级,无需通知用户,用户对升级过程毫无感知。

1.  实现要点

可升级合约是在代理合约的基础上,加入了更换逻辑合约(实现合约)的操作:

  • 可升级合约中需要保存一个指向逻辑合约的地址
  • 可升级合约能够更换指向逻辑合约的地址
  • 可升级合约中更换逻辑合约地址的权限受到管控

为了实现以上目标,可升级合约引入了两个状态变量:

  • 用来保存逻辑合约的地址
  • 用来保存合约管理员的地址

可升级合约增加了一个升级函数 upgrade,用来修改其中的逻辑合约的地址。

需要注意的是,只有合约管理员才能使用升级函数 upgrade

2.  可升级合约的实现代码

// 可升级合约
contract Updatable {
    // 逻辑合约地址
    address public logicContractAddress;
    // 合约管理员地址
    address public admin;
    // 状态数据
    uint256 public value;

    // 构造函数
    constructor(address _logicContractAddress) {
        // 设置逻辑合约地址
        logicContractAddress = _logicContractAddress;
        // 设置合约管理员地址
        admin = msg.sender;
    }
    // 回退函数
    fallback() external {
        // 转发请求给逻辑合约
        (bool success, bytes memory result) = logicContractAddress.delegatecall(msg.data);
        require(success, "Forwarding request to logic contract failed");
        // 返回执行结果给调用者
        assembly {
            return(add(result, 0x20), mload(result))
        }
    }
    // 升级函数
    function upgrade(address _newLogicContractAddress) external {
        // 如果调用者是管理员则放行,否则拒绝
        require(msg.sender == admin, "Only admin can upgrade");
        // 设置新的逻辑合约地址
        logicContractAddress = _newLogicContractAddress;
    }
}

说明:

  •  fallback 函数将接收到的请求转发给逻辑合约,并将执行结果返回给调用者。
  • 合约管理员可以通过 upgrade 函数,更改指向逻辑合约的地址。
  • 当逻辑合约需要升级时,重新部署一个新的逻辑合约,然后使用 upgrade 函数更改为新合约地址。

3.  测试和验证

我们首先编写两个逻辑合约,一个代表旧逻辑合约,另一个代表新逻辑合约。

可升级合约通过升级函数,将旧逻辑合约更换为新逻辑合约。

旧逻辑合约 OldLogic 的实现代码:

// 旧逻辑合约
contract OldLogic {
    address public logicContractAddress;  // 占位
    address public admin;  // 占位
    uint256 public value;  // 业务状态数据

    // 业务逻辑实现
    function foo() external {
        // 实现具体的业务逻辑
        value = 1024;
    }
}

说明: 旧逻辑合约中定义了一个函数 foo,它将状态变量 value 设置为 1024

在这个逻辑合约中,你会看到两个无用的变量 logicContractAddressadmin。它们的作用在本文后面讲述。

 

新逻辑合约 NewLogic 的实现代码:

// 新逻辑合约
contract NewLogic {
    address public logicContractAddress;  // 占位
    address public admin;  // 占位
    uint256 public value;  // 业务状态数据

    // 业务逻辑实现
    function foo() external {
        // 实现具体的业务逻辑
        value = 2048;
    }
}

说明: 新逻辑合约中定义了一个函数 foo,它将状态变量 value 设置为 2048

 

下面,我们要按照旧逻辑合约、新逻辑合约和可升级合约的顺序依次部署。

部署 OldLogic,获得旧逻辑合约的地址:

 

部署 NewLogic,获得新逻辑合约的地址:

 

部署可升级合约 Updatable,需要填入旧逻辑合约的地址:

 

调用旧逻辑合约的 foo 函数:

通过代理合约调用逻辑合约中的函数,既可以单独编写一个测试合约,也可以使用函数签名直接调用。

为了简化验证过程,我们使用函数签名调用方式。

我们先计算出 foo 的函数签名值是 0xc2985578。计算方法如下:

 abi.encodeWithSignature("foo()")

然后在 Remix 下方的 CALLDATA 中,填入 0xc2985578,点击 transact 调用 foo 函数。

我们查看执行结果:点击 value ,可以看到,它的值为 1024

 

调用新逻辑合约的 foo 函数:

upgrade 函数的参数中,填入新逻辑合约的地址,然后点击 upgrade,这样就更换成了新逻辑合约的地址。

 

更换地址成功后,在 Remix 下方的 CALLDATA 中,填入 0xc2985578,点击 transact 调用 foo 函数。

我们查看执行结果:点击 value ,它的值已经由 1024 变为 2048

3.  使用 openzepplin 编写可升级合约

我们在编写自己的可升级合约时,可以通过继承 openzepplinProxy 合约和 Ownable 合约来实现。

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

import "@openzeppelin/contracts/proxy/Proxy.sol";
import "@openzeppelin/contracts/access/Ownable.sol";

// 可升级合约
contract Updatable is Proxy, Ownable {
    // 逻辑合约地址
    address public logicContractAddress;

    // 构造函数
    constructor(address _logicContractAddress) Ownable(msg.sender)  {
        // 设置逻辑合约地址
        logicContractAddress = _logicContractAddress;
    }

    // 升级函数
    function upgrade(address _newLogicContractAddress) public onlyOwner {
        // 设置新的逻辑合约地址
        logicContractAddress = _newLogicContractAddress;
    }

    // 覆盖 openzepplin proxy 合约函数
    function _implementation() internal view override returns (address) {
        return logicContractAddress;
    }
}

4.  代理合约中的数据存储

在使用可升级合约和逻辑合约的模式中,确保这两个合约的状态变量在定义和顺序上的一致性是非常重要的。

Solidity 中,每个状态变量都被分配一个存储插槽(storage slots),用于持久存储合约的状态。

状态变量的定义顺序直接影响着它们在存储中的布局。

当使用可升级合约模式时,可升级合约和逻辑合约共享同一存储空间,逻辑合约的代码将操作可升级合约的存储。

如果可升级合约和逻辑合约的状态变量定义不一致,或者顺序不匹配,就会导致错误地读取或写入存储插槽,引发严重的安全问题。所以,我们要确保这两个合约的状态变量的定义和顺序一致。

例如:

// 可升级合约
contract Updatable {
    // 逻辑合约地址
    address public logicContractAddress;
    // 合约管理员地址
    address public admin;
    // 业务数据
    uint256 public value;
    // ......
}

// 逻辑合约
contract Logic {
    // 占位,与可升级合约保持一致
    address public logicContractAddress; 
    // 占位,与可升级合约保持一致
    address public admin;
    // 业务数据
    uint256 public value; 
    // ...... 
}