关于 upgrade-solidity-contracts
这是面向 Solidity 开发者的 OpenZeppelin 升级指南,覆盖代理模式、可升级合约编写以及 Hardhat 和 Foundry 流程。内容还说明存储兼容规则、版本迁移限制和验证警告的处理方法。
主要功能
- 01介绍 UUPS、Transparent 与 Beacon 代理
- 02说明初始化器与实现合约锁定规则
- 03讲解存储布局及 ERC-7201
- 04列出 Hardhat 与 Foundry 升级流程
- 05提供升级验证问题处理层级
适用场景
- 01设计新合约的代理升级方案
- 02检查升级时的存储兼容性
- 03使用 Hardhat 或 Foundry 部署和升级代理
- 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)。每个函数都会:
- 验证实现合约是否符合升级安全要求(存储布局、初始化器模式、不安全操作码)
- 部署实现合约(如果已部署,则重复使用)
- 部署或更新代理/信标
- 调用初始化器(部署时)
插件会按网络将已部署的实现合约记录在 .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结构体。
处理升级验证问题
当插件发出警告或错误时,请按以下优先级逐步处理:
- 修复根本原因。 确定能否重构代码以彻底消除问题 — 移除有问题的模式,或重构存储。这始终是正确的第一步。
- 如果情况确实安全,则使用代码内注释。 如果不适合重构,并且你已确认被标记的模式实际上是安全的,插件支持使用注释,让你直接在源码中记录这一判断。请查阅已安装插件的文档,了解可用的注释。这些注释能为有意采用的例外情况留下清晰的审计记录 — 只有在评估安全性后才使用,切勿将其作为捷径。
- 如果注释不可行,则使用范围狭窄的标志。 某些情况(例如无法修改的第三方基类)无法在源码中处理。请使用可用的最精确标志,并将其限定到特定结构。
- 将宽泛绕过作为最后手段,并充分了解风险。
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 冲突会映射到同一个存储槽,导致静默存储损坏。