本文是可升级智能合约系列的第三部分,重点介绍了如何使用 OpenZeppelin 的可升级库和 Foundry 安全地编写和部署可升级智能合约,包括正确使用初始化器、维护存储布局、保护升级功能以及使用 Foundry 测试升级。
可升级智能合约系列:第三部分 - Solidity 中的安全 UUPS & 透明代理
介绍
在这一部分,我们将从理论转向实践。我们将演练如何安全地编写和部署可升级的智能合约,使用 OpenZeppelin 的可升级库和 Foundry。在此过程中,我们将重点介绍缓解第二部分中漏洞的最佳实践。这包括正确使用初始化器、维护存储布局、保护升级功能以及使用 Foundry 测试升级。到最后,你应该有一个清晰的蓝图,以安全可靠的方式实施可升级的合约。
项目设置 (Foundry + OpenZeppelin)
我们将使用 Foundry (Forge) 作为我们的开发工具包。Foundry 速度很快,用 Rust 编写,并且在 Solidity 开发中与 Hardhat 一起甚至取代了 Hardhat,越来越受欢迎。OpenZeppelin 在其升级库中提供对 Foundry 的官方支持。
初始设置:
确保你已安装 Foundry(forge 和 cast CLI 工具)。
为项目创建一个新目录,例如 UpgradeableContracts-Tutorial。
在其中运行 forge init(这将设置一个带有示例合约的基本 Foundry 支架,我们可以将其删除)。
我们需要将 OpenZeppelin 的可升级合约和 Foundry 升级库添加为依赖项。使用 forge,我们这样做:
这从 GitHub 拉取库(forge install 的默认行为)dev.to 。之后,在 lib/ 中,你将拥有这两个仓库。
设置 ffi = true 允许 Foundry 脚本使用外部调用(OZ Foundry 插件使用 FFI 调用来为某些检查运行 anvil,并验证实现)。extra_output = ["storageLayout"] 确保在编译时生成存储布局 JSON,升级库将使用该 JSON 来比较版本之间的存储。如果需要进行高级检查,我们还可以启用 build_info 或 ast,但对于基础知识来说并非严格要求。
我们的项目将具有:
src/ 目录,用于 Solidity 合约。
script/ 目录,用于部署脚本。
test/,用于测试。
我们将演示透明和 UUPS 模式。让我们想象一个简单的合约示例来升级,一个计数器或一个我们可以添加新功能的 token。
初始化器 & 可初始化模式
使用初始化器函数。OpenZeppelin 的 Initializable 提供:
initializer 修饰符:允许函数仅运行一次(每次部署)。
onlyInitializing 修饰符:允许父初始化器仅在初始化期间被调用(在父合约中使用,以确保它们仅作为某些子合约初始化的一部分被初始化)。
永远不要将状态初始化逻辑放在构造函数中。可升级实现中唯一推荐的构造函数语句是 _disableInitializers(); 这会锁定实现本身,以便只有代理可以运行 initialize 函数。
让我们创建一个示例:
合约 V1:(我们将其称为 MyContractV1.sol)
这里需要注意几件事:
我们继承了 Initializable 和 OwnableUpgradeable。OwnableUpgradeable 提供了一个 __Ownable_init(),我们必须调用它才能正确设置所有者(当通过代理调用 initialize 时,这将是 msg.sender)。如果我们忘记调用 __Ownable_init(),所有者将保持 address(0),这是一个大错误。
initialize 函数是公共的,并且具有 initializer 修饰符。这确保了它只能被调用一次。插件将在一次调用后通过设置内部标志自动将合约标记为已初始化。
我们设置了一些状态(value, paused)。
该合约包括一个 setValue 函数,该函数在暂停时受到限制。并且 togglePause 只能由所有者调用。
因为 OwnableUpgradeable 使用一个存储槽来存储所有者(继承自 Context/Ownable 的实现),所以我们必须注意存储顺序。按照我们编写 MyContractV1 的方式,继承的 Ownable 的 owner 变量将占用槽 0(很可能,因为 OwnableUpgradeable 可能会声明一个私有地址作为 owner 的第一个变量)。然后 value 将是槽 1,paused 将是槽 2(假设 32 字节的槽,bool 是 1 字节,但由于它是继承之后 MyContract 中的第一个状态变量,因此仍会占用一个完整的槽)。
为了确认存储布局,我们可以运行 forge compile 并检查 MyContractV1 的存储布局输出。(OpenZeppelin 的升级插件也将使用它来确保我们在 v2 中不会出错。)
现在,此合约已准备好进行升级。它没有构造函数,我们将通过代理部署它,因此 initialize 将在代理上调用。
请注意,我们在 MyContractV1 中没有包含存储间隙。这是一个简单的合约。如果我们希望在以后的升级中添加更多变量以避免布局移位,我们应该包含一个存储间隙。如果我们计划只在末尾附加变量,这不是严格要求的,但这是一个好的做法,特别是对于广泛使用的基础合约。
部署代理
准备好合约后,部署可升级实例涉及部署实现和代理,并根据需要设置管理员。OpenZeppelin 的 Foundry Upgrades 库使这变得简单。
让我们通过透明和 UUPS 工作流程,因为它们在管理上略有不同。
透明代理部署(工作流程)
如果使用透明代理(或者你只是调用 Upgrades.deployProxy 而不另行指定,因为它默认为透明),该库将在后台执行以下操作:
部署实现合约(MyContract 逻辑)。
部署一个指向该实现的 TransparentUpgradeableProxy。
如果尚未部署 ProxyAdmin 合约,则部署一个(OpenZeppelin 的 Hardhat 插件默认情况下每个项目使用一个 ProxyAdmin;在 Foundry 中,你可以显式部署一个,或者插件可以创建一个并缓存它)。
使用提供的初始化器参数在代理上调用 initialize(通过 ProxyAdmin 的 initializeProxy 函数)。
所有这些都可以通过 Foundry 脚本或测试中的一行代码来完成:
返回值会给你代理地址和实现地址。然后你可以在 Forge 中创建一个合约Handle:MyContract proxy = MyContract(proxyAddr); 并在测试中与之交互。
几点说明:
adminAddress 是透明代理的关键部分。如果已部署 ProxyAdmin,你可以将其设置为 ProxyAdmin 的地址。如果 OZ Foundry 插件允许你指定一个 EOA 作为管理员,它会部署一个由该 EOA 拥有的 ProxyAdmin(因此 EOA 通过 ProxyAdmin 间接控制升级)。在这里使用多重签名的地址通常更安全。
如果你想在一个管理员下部署多个代理(用于不同的合约),请部署一次 ProxyAdmin 并重复使用它。
该插件确保记录实现的存储布局,并将其用于将来的升级。
部署后,你的透明代理使用非常简单:用户调用代理,代理委托给实现。如果你以后需要升级,你可以在脚本或测试中调用 Upgrades.upgradeProxy(proxyAddr, NewImplementationContractFactory),它将部署新的实现,运行存储检查,如果一切顺利,则通过 ProxyAdmin 执行升级。
重要提示:只有管理员(ProxyAdmin 合约)可以升级透明代理,并且只有管理员可以调用代理上的管理函数。如果普通用户尝试调用代理上的 upgradeTo,透明代理的逻辑将阻止它。相反,管理员无法通过代理调用普通函数(它们将被路由到管理函数空间)。这种分离是自动处理的;作为开发人员,只需注意使用正确的地址。在测试中,你可以通过模拟 ProxyAdmin 来模拟升级。
UUPS 代理部署(工作流程)
UUPS 代理的部署在架构上稍微简单一些(没有使用 ProxyAdmin 合约),但要求实现具有升级功能和对它们的访问控制。
通过 Foundry 插件部署:
通过传递 address(0) 或使用特定的 UUPS 部署函数(如果库提供),你表明它是一个 UUPS 代理。在 UUPS 中,代理的管理员通常由库设置为 0x000...dead(因此没有人可以使用 Transparent 的管理模式)。实现自身的 upgradeTo 函数是升级的发生方式。
部署 UUPS 代理后,你应该立即测试升级功能是否确实受到限制。例如,如果你的逻辑合约继承了 OZ 的 UUPSUpgradeable,你必须覆盖 _authorizeUpgrade(address) 以仅允许所有者(或指定的升级者角色)。一个常见的模式是继承 OwnableUpgradeable 然后:
这确保只有所有者可以调用 upgradeTo。如果部署没有 _authorizeUpgrade 覆盖的 UUPS 实现,OpenZeppelin 插件实际上会警告你,以防止将其保持开放。
要在 Foundry 中升级 UUPS 代理,你可以使用:
如果你没有额外的 initialize 调用要做(即,你没有使用 upgradeToAndCall),你可以只传递空数据或使用不同的函数。该插件将执行:部署新的 impl,调用 validateUpgrade(oldImpl, newImpl),然后从代理管理员的上下文中执行 proxy.upgradeTo(newImpl)(对于 UUPS,实际上是代理本身调用,但 OZ 的 UUPS 模式使用代理地址来调用到实现的 upgradeTo,这是一个有点技术性的细节,基本上代理的回退将委托给 impl 的 upgradeTo,而 impl 的 upgradeTo 又使用代理的存储来设置新的 impl)。
从工作流程的角度来看,在使用插件时 UUPS 和透明看起来很相似:你编写测试以确保在升级后,状态正确并且新功能正常工作。不同之处在于,在透明升级中,你可能必须将 ProxyAdmin 合约作为升级者处理,而在 UUPS 中,你直接处理代理(通过实现上的一个函数)。
Beacon 代理部署:虽然不在大纲中,但只是要注意:Upgrades.deployBeaconProxy 也可用。如果使用 Beacon 模式,你将部署一个 Beacon(带有所有者和实现),然后部署指向它的多个代理。OZ Foundry lib 以类似的方式支持它(它将启动一个 Beacon 合约)。
维护存储布局
为了避免第二部分中可怕的存储冲突和损坏,请在编写合约的新版本时严格遵循这些准则:
永远不要删除或重新排序现有的状态变量。如果 MyContractV1 具有 uint256 x; bool paused;,那么在 MyContractV2 中,它们必须以相同的顺序出现在合约的定义中。你可以在现有变量之后添加新变量。如果你更改变量的类型(例如,将 uint256 更改为 uint128),这实际上是一个重新排序,因为它更改了编译器分配槽的方式,所以也不要这样做。
如果需要,请使用存储间隙:OpenZeppelin 的可升级基础合约通常包含 uint256[50] private gap; 或类似的东西。这是为了为将来使用保留存储槽。如果你预计你的合约可能会获得新的父合约或变量插入其中,则间隙可以提供缓冲空间。例如,OZ 的 ERC20Upgradeable 保留了 45 个槽,以防 ERC20 的未来版本添加变量。你可以类似地在自己的合约中包含一个间隙:例如,在 V1 的末尾添加 uint256[20] private gap;,这允许你在 V2 中添加最多 20 个新变量,而不会与 V1 的布局冲突(如果你不使用所有 20 个,它们将保持未使用状态)。如果你使用其中一些,则相应地减少新实现中的间隙长度,以使总槽数保持不变。
保持继承顺序:如果更改基类或其顺序,请检查这如何影响存储。基的线性化顺序决定了存储布局。最好不要添加本身具有存储的新基合约,除非你将它们附加到继承列表的末尾或非常小心。如果你的做法不正确,插件的存储检查通常会捕获。
检查报告:Foundry(带有 extra_output = ["storageLayout"])将为每个合约生成存储布局的 JSON。你可以手动检查它或依赖 OZ 的插件。例如,如果你不小心重命名了一个变量(这会显示为一个已删除的变量和一个已添加在不同槽中的变量),则插件的 validateUpgrade 将引发错误。注意这些错误;它们是你防止灾难性升级的最后一道防线。
示例:假设 V1 具有:
现在对于 V2,你想添加一个 address public owner。正确的方法:将 V2 定义为:
这样,存储槽可能是:slot0=x、slot1=paused、slot2=owner(新)和 slots 3-52 gap。如果我们没有间隙,添加所有者仍然是可以的(slot2 new),间隙更多的是为了未来证明通过基本合同添加多个或插入。关键是我们没有,例如,将 owner 放在 x 和 paused 之间。那是不行的。
Audius 错误发生的原因是代理引入了一个实现不期望的变量。如果使用 OpenZeppelin 的代理,他们可以通过将管理员存储在随机的高槽中来避免它(根据 EIP-1967)。因此,作为开发人员,不要修改代理的代码以添加存储。如果需要特定于代理的数据,请使用 EIP-1967 槽约定或将其存储在实现中。
简而言之:仅附加存储规则并使用工具进行验证。如果你遵循这一点,你可以避免 99% 的升级存储问题。
保护升级机制
设计你的升级过程,以便即使你的代码是完美的,你也离灾难不是一个私钥之遥。这涉及链上访问控制和链下程序。
访问控制(多重签名/DAO)
不要将升级授权留给单个 EOA(外部拥有的帐户,即单个私钥)。相反,使用多重签名钱包或 DAO 治理合约作为控制器:
在透明代理中,这意味着 ProxyAdmin 合约的所有者应该是多重签名或 DAO。当你使用 deployProxy 时,如果你为了测试的简单性而传递了一个 EOA 作为管理员,请确保在生产环境中这是一个多重签名地址。你也可以稍后将 ProxyAdmin 的所有权转移到多重签名。
在 UUPS 代理中,这意味着 _authorizeUpgrade 上的 onlyOwner 最终应该是多重签名或 DAO 控制的合约。例如,如果你使用 OpenZeppelin 的 OwnableUpgradeable,你可以将合约的 transferOwnership 转移到 Gnosis Safe 多重签名地址。然后,任何升级调用都需要签名者进行 N-of-M 批准,从而显着降低了一个密钥被泄露的风险。
对于 DAO 治理,一种常见的模式是代理管理员实际上是由 Governor 合约控制的 Timelock 合约(如 Compound 和其他合约所做的那样)。然后,升级仅在提案获得投票并通过时间锁排队(例如 2 天)之后才会发生。
如果使用多重签名,请确保多重签名本身是安全的(硬件密钥等)。这样做的目的是避免 PAID 网络场景,其中一个开发密钥被黑客入侵导致升级漏洞。
还要考虑使用单独的角色:也许所有者可以做很多事情,但是你可以拥有一个专门的“升级者”角色,它是多重签名,而其他管理任务(例如暂停合约)可能是一个较小的集合。这样,即使一个管理操作受到威胁,升级仍然不会。
总而言之,使升级授权尽可能严格,因为它实际上是通往王国的钥匙。
时间锁 & 治理
对于真正去中心化的项目或处理大量价值的项目,向升级添加时间锁是一个黄金标准。时间锁合约将在计划升级和执行升级之间强制执行延迟(例如 48 或 72 小时)。这提供了:
社区监督时间:用户可以看到已计划升级以及新的实现地址将是什么(甚至可以验证其代码,如果已发布)。如果他们不喜欢这样,他们可以提取资金或施加治理压力。
防止即时攻击:如果攻击者以某种方式获得了升级权限,他们将无法立即升级到恶意合约;他们会安排它,而延迟提供了响应的机会(也许可以转移资金或通过治理取消)。
例如,Compound 的治理流程在成功投票后,在执行前有 2 天的时间锁。这也适用于升级,因为升级只是在时间锁中排队的交易。
如果你没有使用完整的 DAO,甚至可以在合约上使用硬编码的时间锁(尽管这并不常见)。或者,至少提前在社交渠道中宣布升级,并可能延迟将其添加到 UI 中一段时间。
token 持有者的治理也可以是最终的升级权限。ProxyAdmin 可以由 Governor 合约拥有,而不是多重签名(如 OpenZeppelin 的 Governor with Timelock)。这样,任何升级都需要提案和多数票。缺点是速度,在紧急情况下,投票可能太慢。一些项目保留了多重签名紧急可升级性,可用于快速修补关键错误,但计划转移到完整的治理以进行例行升级。
总之,时间锁和去中心化审批增加了安全性和透明度,但代价是敏捷性。许多项目一旦达到一定规模就采用它们。
禁止/有风险的代码实践
如第二部分所述,不要在可升级的合约实现中包含某些代码:
没有 selfdestruct:实际上没有永远自我销毁实现合约的合法理由。如果你想退役一个系统,你可以简单地将代理指向一个无所事事的实现或撤销权限。自我销毁实现会给代理带来混乱。OpenZeppelin 在其文档中明确禁止这样做。
没有对用户输入的开放 delegatecall:如果你的逻辑合约有任何接受地址和数据的函数并执行 delegatecall 或 call,你必须确保它受到严格限制或已删除。这就是代理可能被诱骗执行任意代码的方式。如果你正在实施类似插件系统的东西,请仔细设计它(例如,仅 delegatecall 到已知安全合约的白名单)。但作为经验法则,最好完全避免在可升级的逻辑中使用它。我们已经有一层 delegatecall(代理 -> 实现);添加另一个可能会使安全性非常难以推理。
小心 extcodehash 或基于地址的逻辑:一些合约使用它们地址是部署代码所在的事实。对于代理,address(this).code 返回代理的代码(这只是 delegatecall 存根)。如果你的逻辑对其自身代码大小或类似内容进行任何检查,它将表现不佳。此外,extcodehash(proxy) 在升级后会发生变化(因为实现代码的哈希值会发生变化),这可能会影响逻辑(如果使用)。通常,避免依赖于可升级内容的链上代码检查,或者如果你这样做,请了解代码会发生变化。
不要使用 tx.origin 进行身份验证:不是专门的升级问题,但对于代理,tx.origin 仍然是外部调用者,并且 msg.sender 被保留,因此身份验证应依赖于 msg.sender(这可以正常工作)。
可重入性考虑:升级本身不会改变可重入性,但是如果你在升级中引入新的状态(如新的互斥锁),请考虑存储布局意味着较旧的函数可能不知道新的互斥锁。未初始化的新变量默认为 0,这对于布尔值可能是“false”,对于 uint 可能是 0。如果你依赖于新变量来确保安全性,请确保在添加时正确设置它。并测试旧函数和新函数之间的交互。使用 OpenZeppelin 的 ReentrancyGuardUpgradeable 很好(只需记住调用它的初始化器)。
保持实现逻辑简单明了,避免底层技巧。专注于业务逻辑;让代理机制处理升级,不要试图在实现中过于聪明。
带有测试的实践升级示例
让我们用一个简单的场景将所有内容放在一起:
场景:我们有一个合约 Counter v1,它只存储一个数字并且可以递增它。我们想升级到 CounterV2,它添加了一个递减函数和一个紧急暂停。
步骤 1:编写 Counter V1
我们包含了一个 48 个槽的间隙(只是一个例子;可能预计在 V2 中添加几个变量)。我们有一个 paused 变量和一个 increment 函数。
步骤 2:编写 Counter V2
在这里,我们:
添加了一个 owner 变量(在 paused 之后的槽)。
添加了一个 initializeV2 来设置所有者(标记为 reinitializer(2),意味着它可以被调用一次,并且只有在合约的初始化版本 <2 时才能调用,确保它仅针对此升级调用)。
添加了一个递减函数和一个暂停/取消暂停的函数(只有所有者可以暂停)。
我们通过继承 Counter 保留了 value 和 paused 位置。在添加 owner 后,我们将间隙从 48 减少到 47。为了简单起见,我们选择通过我们自己的 paused bool 合并 PausableUpgradeable;或者,我们可以继承 PausableUpgradeable(它有自己的 bool 和间隙)。
步骤 3:在 Foundry 中测试升级
我们将编写一个 Foundry 测试(在 Solidity 或 forge std 风格中)来模拟升级:
此测试涵盖了:
部署代理并确保 V1 正常工作。
升级到 V2(Upgrades.upgradeProxy 处理部署新的 impl 并在一个步骤中调用 initializeV2)。
检查升级前的状态(value 和 paused)是否结转,以及新的所有者是否已通过 initializeV2 正确设置。
验证新函数(decrement、setPaused)是否按预期工作并与旧逻辑集成(暂停会影响 increment)。
我们还隐式地测试了存储兼容性:如果我们搞砸了存储,插件的 upgradeProxy 将会抛出异常。例如,如果我们不小心在 V2 中删除了 paused,存储检查会检测到布局不兼容。
模拟不兼容的升级:如果你想看到实际的安全措施,你可以尝试编写一个 CounterV2Bad,例如,将 uint256 public value 更改为 uint128 public value 并部署它。该插件应恢复,并显示有关存储布局不兼容的消息。
要测试的边缘情况
最好测试你不能两次初始化 V2。在我们的例子中,reinitializer(2) 可以防止它,但我们可以添加:
function testCannotReinit() public {
vm.expectRevert();
proxy.initializeV2();
}
在升级后,确保再次调用它会失败。
使用不同的管理员进行测试
我们使用 address(this)(测试合约)作为管理员。当为透明代理提供 EOA(或任何地址)时,Foundry 的 Upgrades 库将部署一个由该地址拥有的 ProxyAdmin。由于我们的测试合约不是 EOA,我在这里为简单起见使用了它(该库可能会将任何地址视为 ProxyAdmin 所有权的接收者)。实际上,我们会在那里使用多重签名地址。在测试中,人们可能会通过使用单独的地址,然后稍后使用 vm.prank 充当它来模拟多重签名,以便通过 ProxyAdmin 进行升级。Foundry 插件目前没有公开直接使用已部署的 ProxyAdmin 的高级调用的方法,但人们可以通过部署 ProxyAdmin 并使用较低级别的函数来手动管理它。
像测试普通合约功能一样测试你的升级。编写单元测试,涵盖升级前和升级后的场景,可能在同一个测试用例中。这使人们有信心,用户的余额、设置等等保持正确,并且新功能正常工作。
验证、审计 & 工具检查
即使经过仔细的开发,也始终验证和审计你的可升级合约:
使用 OpenZeppelin 的验证:正如我们所强调的,Upgrades 插件的自动检查(用于存储、多次初始化等)非常有价值。不要忽略它的警告。如果它标记了某些内容,请修复你的合约,直到它通过为止。你也可以在测试中以编程方式调用 Upgrades.validateUpgrade(refContract, newContract) 以获得布尔值(如果你想在测试中断言这一点)。
手动代码审查:让团队成员或外部审计师审查初始实现和任何后续实现的差异。许多安全公司在审计可升级项目时,会明确审查升级逻辑,并执行存储布局的差异作为流程的一部分。他们还会查看升级治理(谁持有密钥?是否有时间锁?)。
审计特定升级问题:要求审计师特别注意初始化函数、存储间隙和底层调用的使用。向他们提供代理架构(使用哪种模式等)。
使用代理信息在 Etherscan 上验证:在部署到主网或测试网时,请在 Etherscan 上验证你的实现合约。Etherscan 有一个功能可以将合约标记为代理,并链接实现源代码。这有助于社区了解正在发生的事情。如果适用,还要验证 ProxyAdmin。透明性对于在可升级项目中建立用户信任大有帮助。
监控和警报:部署后,考虑使用监控工具。例如,如果实现地址意外更改,请设置警报(许多分析可以监视代理在升级时发出的事件,或者仅检查存储槽 0x...并在其更改时发出警报)。这可以快速捕获未经授权的升级。
像 Diff 测试这样的工具:如第 4 部分所述,像 DiffUZZ (Diffusc) 这样的高级工具可以在合约版本之间执行差异模糊测试。如果你有一个非常关键的升级,你可能会使用这样的工具来生成随机测试,这些测试会比较 V1 和 V2 对于各种输入的行为,从而确保除了你期望的更改之外,没有其他意外更改。例如,它可以捕获某些函数输出以不应有的方式更改。这就像一个自动回归测试生成器。
Immunefi 漏洞赏金:考虑启动一个专注于你的可升级性的漏洞赏金计划。白帽黑客可能会在你的升级机制中发现自动化工具遗漏的问题,例如微妙的治理漏洞或多步漏洞利用。
结论
在第 3 部分中,我们逐步介绍了如何在 2025 年使用最新的 OpenZeppelin 工具并遵循最佳实践来实现和升级 Solidity 合约。通过仔细使用初始化器、维护存储布局以及将升级访问权限限制为受信任的多重签名/DAO 所有者,我们减轻了主要风险。我们还学习了使用 Foundry 来测试升级过程本身,从而确保状态的连续性和新功能。凭借这种实践知识,人们可以自信地管理可升级的合约,同时避免早期时代的陷阱。接下来,在第 4 部分中,我们将探讨可升级合约的发展方向,从社区控制到新的以太坊改进提案,这些提案可能会重新定义未来升级的工作方式。请继续关注我们,因为我们将考虑可升级智能合约的未来趋势和长期前景。
- 原文链接: x.com/threesigmaxyz/stat...
- 登链社区 AI 助手,为大家转译优秀英文文章,如有翻译不通的地方,还请包涵~
如果觉得我的文章对您有用,请随意打赏。你的支持将鼓励我继续创作!