升级Solidity合约

这篇文章详细介绍了如何使用 OpenZeppelin 代理模式升级 Solidity 智能合约,涵盖了 UUPS、Transparent 和 Beacon 代理模式的原理与应用,强调了初始化函数、存储布局规则(特别是 ERC-7201 命名空间存储),并提供了 Hardhat 和 Foundry 插件的具体工作流程及升级安全注意事项。

目录

代理模式概述

模式 升级逻辑所在 最适合
UUPS (UUPSUpgradeable) 实现合约(重写 _authorizeUpgrade 大多数项目 — 更轻量的代理,更低的部署gas成本
Transparent 独立的 ProxyAdmin 合约 当管理员/用户调用分离至关重要时 — 管理员不会意外调用实现函数
Beacon 共享信标合约 多个代理共享一个实现 — 升级信标可原子化升级所有代理

所有这三种模式都使用 EIP-1967 存储槽来存储实现地址、管理员和信标。

透明代理 — v5 构造函数变更: 在 v5 中,TransparentUpgradeableProxy 会自动部署自己的 ProxyAdmin 合约,并将管理员地址存储在一个不可变变量中(在构造时设置,永不更改)。第二个构造函数参数是该自动部署的 ProxyAdmin所有者地址不要在此处传递现有的 ProxyAdmin 合约地址。升级能力的转移完全通过 ProxyAdmin 所有权进行处理。这与 v4 不同,v4 中 ProxyAdmin 是单独部署的,其地址被传递给代理构造函数。

主要版本之间的升级限制 (v4 → v5)

不支持将代理的实现从使用 OpenZeppelin Contracts v4 升级到使用 v5。

v4 使用顺序存储(按声明顺序排列的槽);v5 使用命名空间存储 (ERC-7201),其中结构体位于确定性槽位。v5 实现无法安全读取 v4 实现写入的状态。手动数据迁移理论上可行但通常不切实际 — mapping 条目无法枚举,因此任意键下写入的值无法重新定位。

推荐方法: 部署带有 v5 实现的新代理,并将用户迁移到新地址 — 不要升级当前指向 v4 实现的代理。

鼓励将你的代码库更新到 v5。 上述限制仅适用于已部署的代理。基于 v5 的新部署以及同一主要版本内的升级是完全支持的。

编写可升级合约

使用初始化器而不是构造函数

代理合约会 delegatecall 到实现合约。构造函数只在实现合约本身部署时运行,而不是在创建代理时运行。用初始化器函数替换构造函数:

import {Initializable} from "@openzeppelin/contracts/proxy/utils/Initializable.sol";

contract MyToken is Initializable, ERC20Upgradeable, OwnableUpgradeable {
    /// @custom:oz-upgrades-unsafe-allow constructor
    constructor() {
        _disableInitializers(); // 锁定实现
    }

    function initialize(address initialOwner) public initializer {
        __ERC20_init("MyToken", "MTK");
        __Ownable_init(initialOwner);
    }
}

关键规则:

  • 顶层 initialize 使用 initializer 修饰符
  • 父级初始化函数 (__X_init) 在内部使用 onlyInitializing — 显式调用它们,编译器不会像构造函数那样自动线性化初始化器
  • 始终在构造函数中调用 _disableInitializers() 以防止攻击者直接初始化实现
  • 不要在字段声明中设置初始值(例如,uint256 x = 42)— 这些会编译到构造函数中,并且不会为代理执行。constant 是安全的(在编译时内联)。immutable 值存储在字节码中并由所有代理共享 — 插件默认将其标记为不安全;当共享值是预期行为时,使用 /// @custom:oz-upgrades-unsafe-allow state-variable-immutable 来选择启用

使用 upgradeable 包

@openzeppelin/contracts-upgradeable 导入基础合约(例如,ERC20UpgradeableOwnableUpgradeable)。从 @openzeppelin/contracts 导入接口和库。在 v5.5+ 中,InitializableUUPSUpgradeable 也应直接从 @openzeppelin/contracts 导入 — upgradeable 包中的别名将在下一个主要版本中移除。

存储布局规则

升级时,新实现必须与旧实现存储兼容:

  • 切勿 重新排序、移除或更改现有状态变量的类型
  • 切勿 在现有变量之前插入新变量
  • 只能 在末尾添加新变量
  • 切勿 更改基础合约的继承顺序

命名空间存储 (ERC-7201)

现代方法 — 所有 @openzeppelin/contracts-upgradeable 合约 (v5+) 都使用此方法。状态变量被分组到一个结构体中,位于一个确定性的存储槽,从而隔离了每个合约的存储,并消除了对存储间隙的需求。推荐用于所有可能作为基础合约导入的合约。

/// @custom:storage-location erc7201:example.main
struct MainStorage {
    uint256 value;
    mapping(address => uint256) balances;
}

// keccak256(abi.encode(uint256(keccak256("example.main")) - 1)) & ~bytes32(uint256(0xff))
bytes32 private constant MAIN_STORAGE_LOCATION = 0x...;

function _getMainStorage() private pure returns (MainStorage storage $) {
    assembly { $.slot := MAIN_STORAGE_LOCATION }
}

使用命名空间存储中的变量:

function _getBalance(address account) internal view returns (uint256) {
    MainStorage storage $ = _getMainStorage();
    return $.balances[account];
}

相较于传统存储间隙的优势:安全地向基础合约添加变量,继承顺序的更改不会破坏布局,每个合约的存储完全隔离。

升级时,切勿通过将其从继承链中删除来移除命名空间。插件会将已删除的命名空间标记为错误 — 该命名空间中存储的状态将成为孤立数据:数据仍保留在链上,但新实现无法读取或写入它。如果某个命名空间不再活跃使用,请将其旧合约保留在继承链中。未使用的命名空间不会增加运行时成本,也不会导致存储冲突。没有专门的标记来抑制此错误;唯一的绕过方法是 unsafeSkipStorageCheck,它会禁用所有存储布局兼容性检查,并且是危险的最后手段。

计算 ERC-7201 存储位置

在生成命名空间存储代码时,始终计算实际的 STORAGE_LOCATION 常量。使用 Bash 工具运行以下命令,其中包含实际的命名空间 ID,并将计算出的值直接嵌入到生成代码中。切勿留下 0x... 这样的占位符值。

公式为:keccak256(abi.encode(uint256(keccak256(id)) - 1)) & ~bytes32(uint256(0xff)),其中 id 是命名空间字符串(例如,"example.main")。

Node.js 配合 ethers:

node -e "const{keccak256,toUtf8Bytes,zeroPadValue,toBeHex}=require('ethers');const id=process.argv[1];const h=BigInt(keccak256(toUtf8Bytes(id)))-1n;console.log(toBeHex(BigInt(keccak256(zeroPadValue(toBeHex(h),32)))&~0xffn,32))" "example.main"

"example.main" 替换为实际的命名空间 ID,运行该命令,然后使用输出作为常量值。

不安全操作

  • 不要使用 selfdestruct — 在 Dencun 之前的链上,它会销毁实现合约并使所有代理失效。Dencun 之后(EIP-6780),selfdestruct 仅在与创建相同的交易中调用时销毁代码,但插件仍将其标记为不安全
  • 不要对不受信任的合约使用 delegatecall — 恶意目标可能会 selfdestruct 或破坏存储

此外,避免在可升级合约内部使用 new 创建合约 — 创建的合约将不可升级。而是注入预部署的地址。

Hardhat 升级工作流

安装插件:

npm install --save-dev @openzeppelin/hardhat-upgrades
npm install --save-dev @nomicfoundation/hardhat-ethers ethers  # 对等依赖

hardhat.config 中注册:

require('@openzeppelin/hardhat-upgrades'); // JavaScript
import '@openzeppelin/hardhat-upgrades';   // TypeScript

工作流概念 — 插件在 upgrades 对象上提供函数 (deployProxy, upgradeProxy, deployBeacon, upgradeBeacon, deployBeaconProxy)。每个函数:

  1. 验证实现的升级安全性(存储布局、初始化器模式、不安全操作码)
  2. 部署实现(如果已部署则重用)
  3. 部署或更新代理/信标
  4. 调用初始化器(部署时)

插件在 .openzeppelin/ 中按网络文件跟踪已部署的实现。将非开发网络文件提交到版本控制。

使用 prepareUpgrade 来验证和部署新实现而不执行升级 — 在多重签名或治理合约持有升级权限时非常有用。

请查阅已安装插件的 README 或源代码以获取准确的 API 签名和选项,因为这些会随着版本演进而变化。

Foundry 升级工作流

安装依赖:

forge install foundry-rs/forge-std
forge install OpenZeppelin/openzeppelin-foundry-upgrades
forge install OpenZeppelin/openzeppelin-contracts-upgradeable

配置 foundry.toml

[profile.default]
ffi = true
ast = true
build_info = true
extra_output = ["storageLayout"]

Node.js 是必需的 — 该库会调用 OpenZeppelin Upgrades CLI 进行验证。

在脚本/测试中导入和使用:

import {Upgrades} from "openzeppelin-foundry-upgrades/Upgrades.sol";

// 部署
address proxy = Upgrades.deployUUPSProxy(
    "MyContract.sol",
    abi.encodeCall(MyContract.initialize, (args))
);

// 重要:在升级之前,使用以下注释标记 MyContractV2:/// @custom:oz-upgrades-from MyContract

// 升级并调用函数
Upgrades.upgradeProxy(proxy, "MyContractV2.sol", abi.encodeCall(MyContractV2.foo, ("arguments for foo")));

// 升级不调用函数
Upgrades.upgradeProxy(proxy, "MyContractV2.sol", "");

与 Hardhat 的主要区别:

  • 合约通过名称字符串引用,而非工厂对象
  • 无自动实现跟踪 — 使用 @custom:oz-upgrades-from 注释新版本,或在 Options 结构体中传递 referenceContract
  • UnsafeUpgrades 变体跳过所有验证(接受地址而非名称)— 切勿在生产脚本中使用
  • 在运行脚本之抢跑 forge clean 或使用 --force

请查阅已安装库的 Upgrades.sol 以获取完整的 API 和 Options 结构体。

处理升级验证问题

当插件标记警告或错误时,请按照此层次结构进行处理:

  1. 解决根本原因。 确定代码是否可以重构以完全消除问题 — 移除有问题的模式或重构存储。这始终是正确的第一步。
  2. 如果情况确实安全,使用代码内注解。 如果重构不合适,并且你已确定被标记的模式实际上是安全的,插件支持注解,让你可以在源代码中直接记录该判断。查阅已安装插件的文档以了解可用选项。这些注解为有意例外创建了清晰的审计跟踪 — 仅在评估安全性后使用它们,而不是作为捷径。
  3. 如果注解不起作用,使用窄范围标记。 某些情况(例如,你无法修改的第三方基础合约)无法在源代码中解决。使用最精确的可用标记,并将其限定在特定构造上。
  4. 作为最后手段的广泛绕过,并充分意识到风险。UnsafeUpgrades (Foundry) 或一揽子 unsafeAllow 条目这样的选项会跳过受影响范围内的所有验证。如果你使用它们,请说明原因,并手动验证 — 插件将不再保护你。

升级安全检查清单

  • [ ] 存储兼容性:不重新排序、移除或更改现有变量的类型。只添加新变量(或向命名空间结构体添加字段)。
  • [ ] 初始化器保护:顶层 initialize 使用 initializer 修饰符。实现合约构造函数调用 _disableInitializers()
  • [ ] 父级初始化器被调用:每个继承的、可升级合约的 __X_initinitialize 中只被调用一次。
  • [ ] 无不安全操作码:不使用 selfdestruct 或对不受信任的目标进行 delegatecall
  • [ ] 函数选择器冲突:代理管理函数和实现函数不得共享选择器。UUPS 和 Transparent 模式在设计上解决了这个问题;自定义代理需要手动审查。
  • [ ] UUPS _authorizeUpgrade:使用适当的访问控制(例如 onlyOwner)进行重写。忘记这一点会使代理不可升级或可被任何人升级。
  • [ ] 测试升级路径:部署 V1,升级到 V2,验证状态是否保留以及新逻辑是否正常工作。Hardhat 和 Foundry 插件都可以在测试套件中验证升级。
  • [ ] V2+ 的重新初始化器:如果 V2 需要新的初始化逻辑,使用 reinitializer(2) 修饰符(而不是只能运行一次的 initializer)。
  • [ ] 唯一的 ERC-7201 命名空间 ID:继承链中没有两个合约共享相同的命名空间 ID。冲突的 ID 会映射到相同的存储槽,导致静默的存储损坏。
  • 原文链接: github.com/OpenZeppelin/...
  • 登链社区 AI 助手,为大家转译优秀英文文章,如有翻译不通的地方,还请包涵~
点赞 0
收藏 0
分享
本文参与登链社区写作激励计划 ,好文好收益,欢迎正在阅读的你也加入。

0 条评论

请先 登录 后评论
OpenZeppelin
OpenZeppelin
江湖只有他的大名,没有他的介绍。