这篇文章详细介绍了如何使用 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是单独部署的,其地址被传递给代理构造函数。
不支持将代理的实现从使用 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 来选择启用从 @openzeppelin/contracts-upgradeable 导入基础合约(例如,ERC20Upgradeable、OwnableUpgradeable)。从 @openzeppelin/contracts 导入接口和库。在 v5.5+ 中,Initializable 和 UUPSUpgradeable 也应直接从 @openzeppelin/contracts 导入 — upgradeable 包中的别名将在下一个主要版本中移除。
升级时,新实现必须与旧实现存储兼容:
现代方法 — 所有 @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,它会禁用所有存储布局兼容性检查,并且是危险的最后手段。
在生成命名空间存储代码时,始终计算实际的 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 创建合约 — 创建的合约将不可升级。而是注入预部署的地址。
安装插件:
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)。每个函数:
插件在 .openzeppelin/ 中按网络文件跟踪已部署的实现。将非开发网络文件提交到版本控制。
使用 prepareUpgrade 来验证和部署新实现而不执行升级 — 在多重签名或治理合约持有升级权限时非常有用。
请查阅已安装插件的 README 或源代码以获取准确的 API 签名和选项,因为这些会随着版本演进而变化。
安装依赖:
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 结构体中传递 referenceContractUnsafeUpgrades 变体跳过所有验证(接受地址而非名称)— 切勿在生产脚本中使用forge clean 或使用 --force请查阅已安装库的
Upgrades.sol以获取完整的 API 和Options结构体。
当插件标记警告或错误时,请按照此层次结构进行处理:
UnsafeUpgrades (Foundry) 或一揽子 unsafeAllow 条目这样的选项会跳过受影响范围内的所有验证。如果你使用它们,请说明原因,并手动验证 — 插件将不再保护你。initialize 使用 initializer 修饰符。实现合约构造函数调用 _disableInitializers()。__X_init 在 initialize 中只被调用一次。selfdestruct 或对不受信任的目标进行 delegatecall。_authorizeUpgrade:使用适当的访问控制(例如 onlyOwner)进行重写。忘记这一点会使代理不可升级或可被任何人升级。reinitializer(2) 修饰符(而不是只能运行一次的 initializer)。
- 原文链接: github.com/OpenZeppelin/...
- 登链社区 AI 助手,为大家转译优秀英文文章,如有翻译不通的地方,还请包涵~
如果觉得我的文章对您有用,请随意打赏。你的支持将鼓励我继续创作!