5

Solidity 非权威开发指南(5):合约升级

 1 year ago
source link: https://blog.dteam.top/posts/2023-01/solidity-indefinitive-guide-part5.html
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.
neoserver,ios ssh client

所有以太坊开发者都清楚以太坊世界的一条铁律:合约一旦发布就无法修改。因此,对于合约的发布基本上都采用一种慎之又慎的态度,期望在发布前可以做到尽善尽美,力争合约能正常运行一万年。

可是,智者千虑必有失,合约发布百分百不出问题几乎是不可能任务。一些小问题或许还可以通过类似口头约定的方式让大家克服克服,但对于重大问题,恐怕就不得不重新发布新版了。于是乎,一系列连带更新也随之而来:合约调用方、封装合约的 SDK/API 方……搞不好还会牵涉到下一级的连带更新。比如,调用该合约的合约将地址硬编码到代码里且没有提供 setter 来改变该值……太麻烦啦!

鉴于此,可升级合约的呼声越来越高,同时也衍生了各类方案。

什么是可升级合约

“可升级”意味着可修改,这似乎与以太坊强调的 immutable 相矛盾。但让我们再深入思考一下“可升级”的内涵:

  1. 合约地址不能变
  2. 合约状态不能丢失
  3. 合约的行为可变

编程经验丰富的老兵此时应该会拍大腿大声叫道:引入一个中间层就可以做到! 的确如此,可升级合约技术方案的本质就是:proxy + implementation 的分离,见下图:

proxy
  1. proxy 作为调用方和实现方的中间人使“地址不变”成为可能。
  2. 将 implementation 的状态保存于 proxy 中使“状态不丢失”成为可能,这一点只需在 proxy 中使用 fallback + delegatecall 将调用转发给 implementation 即可实现。
  3. 可动态注入不同的 implementation 使得“行为可变”成为可能。

可参见本系列的第二篇快速了解 solidity 语法。

How-To

使用 OpenZepplin Upgrade Plugin 可以让编写可升级合约的事情变得简单,并且考虑到 OpenZepplin 已成为合约开发中事实上的标准库以及编写可升级合约的种种限制,建议无脑采用,最简例子见下:

pragma solidity ^0.8.9;

import "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol";

contract MyContract is Initializable {
    uint256 public x;

    function initialize(uint256 _x) public initializer {
        x = _x;
    }
}
// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.9;

import "./MyContract.sol";

contract MyContractV2 is MyContract {
    uint256 public y;
}
import { ethers, upgrades } from "hardhat";

async function main() {
  const MC = await ethers.getContractFactory("MyContract");
  const mc = await upgrades.deployProxy(MC, [42]);
  await mc.deployed();
  console.log("MyContract deployed to:", mc.address);
}

main();
import { ethers, upgrades } from "hardhat";

const MC_ADDRESS = "部署脚本显示的地址";

async function main() {
  const MCV2 = await ethers.getContractFactory("MyContractV2");
  await upgrades.upgradeProxy(MC_ADDRESS, MCV2);
  console.log("MyContract upgraded");
}

main();

注意事项:

  1. 记得在 hardhat.config.ts 中引入下面语句完成初始化。

    import "@openzeppelin/hardhat-upgrades";
    
  2. 上述脚本需要 network 参数,即至少要运行本地测试网络:

    npx hardhat node
    

编写可升级合约并不是 free style,必须遵循一定的规矩。

限制 1:跟构造函数 say no

原因在于两点:

  1. 从语言限制上来讲,构造函数在合约部署后不属于合约的 runtime bytecode,可简单理解为部署后就消失不见了。
  2. 从逻辑上来讲,构造函数的执行应该只有一次,即使在升级的背景下,也应遵循这个原则。但是,升级合约的实质是“部署并替换”,这种情况下无法保证这一点。

因此,可以看到,在上面的例子中都没有使用构造函数,转而使用所谓的 initialize() 来完成初始化。同时,为了保证该函数只运行一次,还使用了 OpenZepplin 提供的 initializer modifier。

同理,也不要使用初始化声明,即类似下面的语句:

uint256 public hasInitialValue = 42; // X

但是,constant 例外,即以下语句没有问题:

uint256 public constant hasInitialValue = 42 // √

限制 2:initialize() 只能执行一次

原因:见上。代码实现的注意点:

  • 合约继承 Initializable
  • 使用 initializer modifier
  • 使用依赖注入来获得灵活性,上例就是如此,避免在该函数中使用硬编码。
  • 在合约构造函数中调用 _disableInitializers(),这主要是出于安全考虑。这时构造函数为:
/// @custom:oz-upgrades-unsafe-allow constructor
constructor() {
    _disableInitializers();
}

限制 3:父合约的初始化也遵循 1

原因依旧同 1。

对于父合约,同样不能有构造函数,所有的初始化代码需挪到 initialize() 中,只是此时不能使用 initializer modifier,而需用 onlyInitializing modifier 来代替。原因也很简单:若是前者,一旦被子合约的初始化函数调用,父合约的初始化函数就只能执行一次,显然不合继承的语义。

OpenZepplin 提供了 @openzeppelin/contracts-upgradeable 来帮助已经熟悉了 @openzeppelin/contracts 的开发人员来编写可升级合约。前者提供了后者合约的可升级版,如 ERC721Upgradeable.sol 对应 ERC721.sol

限制 4:可兼容的存储布局

其中原因在于 solidity 的语言技术细节,未来会有专文细说。在此只需记住以下规则:相对于老版本合约,

  1. 新版本合约中的变量声明

  2. 当继承多个合约时,新版本的继承顺序不变
  3. 父合约中的变量声明同样需要遵循:

规则 3 于 1 的区别:没有“只增不删”!

其原因很容易理解,因为在父合约中新增变量后会破坏子合约的存储布局。但问题是父合约本身也会演化,必然也有新增变量的需求。为了解决这个问题,可以使用 storage gap 的技巧来解决。说白了,就是:预留存储。

// v1
contract Base {
    uint256 base1;
    uint256[49] __gap;
}

// v2
contract Base {
    uint256 base1;
    uint256 base2;
    uint256[48] __gap;
}

上述代码中,v1 和 v2 的 Base 是存储布局兼容的。

变量类型的长度关系重大,若使用 uint128,则可用两个。即:用连续两个 uint128 变量替代一个 uint256 变量。

限制 5:不要在子合约使用危险操作,如 delegatecallselfdestruct

原因:当 implementation 地址已知后,其他第三方可以不通过 proxy 直接调用它。

虽然你可以在 implementation 里限制调用方的地址,但并不是所有情况下都可以这么做。因此避免危险操作是上策。

限制 6:确保使用可升级库

范围: import 的合约和 lib,确保它们可以正常工作于可升级场景。

除了 OpenZeppelin,还可以看看这个库 solidstate-solidity。正如其 readme 所言:Upgradeable-first Solidity smart contract development library . 未来或许有介绍它的专门文章。

Proxy Patterns

proxy 是可升级合约的底层技术基础,了解其典型模式有助于更好地编程。典型的 proxy pattern 有:

  • Transparent Proxy
  • Beacon
  • Diamond

OpenZeppelin 对于前三者提供了支持,暂时不支持 diamond。相比起前三者,diamond 更复杂并且野心也更大,期望提供一种通用的支持可扩展合约开发的架构模式,它在 solidstate 中得到了广泛的应用。但由于相对复杂,此文略过。

对于前三种:

  • Beacon 的应用范围不如前两种广泛,但它支持不同代理升级到不同实现;
    • 但个人认为,若真是有这样的需求,不如直接采用 diamond 可能更好。
  • Transparent Proxy 拥有更长的历史,OpenZeppelin 的可升级库最早基于它完成。
  • UUPS 则属于后起之秀,相比 Transparent Proxy,它更轻量也更通用,这也意味着它的升级逻辑更便宜。因此 OpenZeppelin 推荐优先使用它。

在 OpenZeppelin 合约库中,三种 proxy pattern 都有对应的实现,并且文档也提供了相应的示例和部署/升级脚本,在此就不再赘述。由于文档中并没有给出 UUPS 的范例,这里简单的描述一下。针对前面的例子:

pragma solidity ^0.8.9;

import "@openzeppelin/contracts-upgradeable/proxy/utils/UUPSUpgradeable.sol";
import "@openzeppelin/contracts-upgradeable/access/OwnableUpgradeable.sol";

contract MyContractUups is UUPSUpgradeable, OwnableUpgradeable {
    uint256 public x;

    function initialize(uint256 _x) public initializer {
        x = _x;
        __Ownable_init();
    }

    function _authorizeUpgrade(address newImplementation) internal override onlyOwner {}
}

其他的 v2 合约和部署/更新几乎一样。

Transparent Proxy、UUPS 和 Beacon 的主要区别主要两点:

  • 是否需要 proxy admin
    • 在 Transparent Proxy 中,该组件负责完成 upgrade 逻辑。但 UUPS 和 Beacon 中都没有它。因此,Transparent Proxy 存在有 admin owner 的概念,同时其 ownership 也可以转移。
    • UUPS 的升级逻辑由 implementation 完成,可以看到上面的代码示例中,它覆盖了 _authorizeUpgrade
    • Beacon 的升级逻辑则由 beacon 的 owner 完成。
  • 在哪存放 implementation 的地址
    • Transparent Proxy 和 UUPS 都将该地址存在 proxy 合约中。
    • Beacon 则将其存放在 beacon 合约里。

EIP1882

支撑 UUPS 的标准是 EIP1822,有兴趣的可以自行了解。此外,从 OpenZepplin 的接口文档和代码也可了解其细节。

EIP1967

关于 implementation 的地址保存,前文说过:它存放于 proxy 合约中。但同时,支撑 proxy 的技术基础又是 delegatecall。它的特性是执行的上下文是 caller 的上下文而非 callee 的上下文。即,任何状态的变化其实发生在 caller 的空间。

那么随之而来的问题是:如果 proxy 中自己有变量定义,同时将调用转发给 implementation 时又会保留它的状态,那么此时必然会导致有冲突。

storage collision

EIP1967 便是为了解决这个问题,定义了一组标准存储槽来解决这个问题。本质上是对 proxy 中的变量存储槽进行了伪随机化处理。

即 proxy 和 implementation 中出现同名函数时,到底该不该转发?这可以通过 caller 来处理,以 Transparant Proxy 为例:

  • 若是 admin,则不转发
  • 否则,总是转发

至此,关于可升级合约的基本要点已经罗列完成,剩下的就是去挖掘相关的代码和文档啦!


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK