mcpField
U
upgrade-solidity-contracts

upgrade-solidity-contracts

github.com/openzeppelin开发219477 次安装2 天前更新
代码仓库

关于 upgrade-solidity-contracts

这是面向 Solidity 开发者的 OpenZeppelin 升级指南,覆盖代理模式、可升级合约编写以及 Hardhat 和 Foundry 流程。内容还说明存储兼容规则、版本迁移限制和验证警告的处理方法。

主要功能

  1. 01介绍 UUPS、Transparent 与 Beacon 代理
  2. 02说明初始化器与实现合约锁定规则
  3. 03讲解存储布局及 ERC-7201
  4. 04列出 Hardhat 与 Foundry 升级流程
  5. 05提供升级验证问题处理层级

适用场景

  1. 01设计新合约的代理升级方案
  2. 02检查升级时的存储兼容性
  3. 03使用 Hardhat 或 Foundry 部署和升级代理
  4. 04评估验证警告及不安全操作

安装

复制下面的命令,在终端中运行。

$npx skills add https://github.com/openzeppelin/openzeppelin-skills --skill upgrade-solidity-contracts

安装后在 Claude Code 中输入/upgrade-solidity-contracts确认已加载。

详细介绍

Solidity 升级

目录

代理模式概览

模式升级逻辑所在位置适用场景
UUPS(UUPSUpgradeable)实现合约(重写 _authorizeUpgrade)大多数项目——代理更轻量,部署 Gas 更低
Transparent独立的 ProxyAdmin 合约管理员与用户调用的隔离至关重要时——管理员不会意外调用实现合约的函数
Beacon共享的 Beacon 合约多个代理共享一个实现时——升级 Beacon 即可原子性地升级所有代理

这三种模式都使用 EIP-1967 存储槽来存储实现合约地址、管理员和 Beacon。

Transparent 代理——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(); // lock the implementation
    }

    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 选择启用
使用可升级包

基础合约(例如 ERC20Upgradeable、OwnableUpgradeable)请从 @openzeppelin/contracts-upgradeable 导入。接口和库请从 @openzeppelin/contracts 导入。在 v5.5+ 中,Initializable 和 UUPSUpgradeable 也应直接从 @openzeppelin/contracts 导入——可升级包中的别名将在下一个主版本中移除。

存储布局规则

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

  • 绝不重新排序、移除或更改现有状态变量的类型
  • 绝不在现有变量之前插入新变量
  • 仅在末尾追加新变量
  • 绝不更改基合约的继承顺序
命名空间存储(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")。

使用 ethers 的 Node.js:

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  # peer dependencies

在 hardhat.config 中注册:

require('@openzeppelin/hardhat-upgrades'); // JS
import '@openzeppelin/hardhat-upgrades';   // TS

工作流概念 — 插件会在 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";

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

// IMPORTANT: Before upgrading, annotate MyContractV2 with: /// @custom:oz-upgrades-from MyContract

// Upgrade and call a function
Upgrades.upgradeProxy(proxy, "MyContractV2.sol", abi.encodeCall(MyContractV2.foo, ("arguments for foo")));

// Upgrade without calling a function
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()。
  • 调用父合约初始化函数:在 initialize 中,每个继承的可升级合约的 __X_init 都恰好调用一次。
  • 不使用不安全操作码:不得使用 selfdestruct 或对不受信任的目标执行 delegatecall。
  • 函数选择器冲突:代理管理员函数和实现合约函数不得共用选择器。UUPS 和 Transparent 模式通过设计避免了此问题;自定义代理需要手动检查。
  • UUPS _authorizeUpgrade:重写该函数并设置适当的访问控制(例如 onlyOwner)。遗漏此项会导致代理无法升级,或任何人都能升级。
  • 测试升级路径:部署 V1,升级到 V2,并验证状态得到保留且新逻辑正常工作。Hardhat 和 Foundry 插件都可以在测试套件中验证升级。
  • V2 及更高版本的重新初始化函数:如果 V2 需要新的初始化逻辑,请使用 reinitializer(2) 修饰符(不要使用只能运行一次的 initializer)。
  • 唯一的 ERC-7201 命名空间 ID:继承链中的任意两个合约都不得共用同一个命名空间 ID。ID 冲突会映射到同一个存储槽,导致静默存储损坏。

评论

支持 Markdown