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

  • 胡键
  • 更新于 2023-02-08 08:19
  • 阅读 3074

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

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

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

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

什么是可升级合约

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

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

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

1675306762096

其中:

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

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

How-To

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

  • 合约 v1
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;
    }
}
  • 合约 v2
// 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
  • UUPS
  • 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 时又会保留它的状态,那么此时必然会导致有冲突。

1675306924844

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

函数冲突

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

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

总结

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


相关文章:


本文首发于:https://blog.dteam.top/posts/2023-01/solidity-indefinitive-guide-part5.html

点赞 1
收藏 5
分享
本文参与登链社区写作激励计划 ,好文好收益,欢迎正在阅读的你也加入。

0 条评论

请先 登录 后评论
胡键
胡键
CSM / 架构师 / 创业者,先后就职于中兴和 SAP,现专注于工业物联网、机器学习和区块链。同时,作为机器学习和区块链技术活动的组织者和分享者活跃于本地社区。