升级智能合约是一个多步骤且容易出错的过程,因此为了将人为错误的几率降至最低,理想的做法是使用能够尽可能自动化该流程的工具。
因此,OpenZeppelin Upgrade Plugin 简化了使用 Foundry 或 Hardhat 构建的智能合约的部署、升级和管理。
在本文中,我们将学习如何结合 Foundry 使用 Upgrade Plugin 来管理合约升级(包括在本地和 Sepolia Testnet 上)。我们还将探讨这些插件如何防范常见的升级问题。
前提条件
为了充分利用本指南,读者应熟悉:
- Solidity 中的 delegatecall 操作。
- ERC1967 等标准以及 initializers 在设置可升级合约状态中的作用。
- 常见的升级失败原因,包括 function selector 和 storage slot 冲突。
- 了解常见的代理模式,如 Transparent Upgradeable Proxy、UUPS Proxy 或 Beacon Proxy。
- Namespace(命名空间)存储布局或 EIP-7201。
Foundry 的 Upgrades 插件
Foundry 的 OpenZeppelin 插件是一个实用工具,可以导入到 Foundry 的 Solidity 脚本或单元测试中,其导入方式如下:
import { Upgrades } from "openzeppelin-foundry-upgrades/Upgrades.sol";
该库公开了用于部署代理、逻辑实现合约(implementation contracts)及其他相关合约的函数。在下一节中,我们将提供其功能的高级概述,随后展示如何编写用于升级的单元测试,并创建一个有助于使用此插件部署和升级智能合约的脚本。
Upgrade Plugins 的特性
- 给定先前智能合约逻辑实现的引用,该插件会将先前的逻辑实现与新实现进行比较,以检查潜在问题,例如 storage slot 冲突以及我们稍后将讨论的其他问题。
- 插件支持部署和升级 UUPS、Transparent 和 Beacon Proxy Patterns。它不支持 Diamond Proxy Pattern。
- 首次使用此插件部署可升级合约时,最多会自动创建三个组件(具体取决于升级模式是 UUPS、Transparent 还是 Beacon Proxy):
- The implementation contract(逻辑实现合约):包含合约的实际逻辑。
- Proxy(代理):如果部署新代理,插件会处理其创建并将其链接到指定的逻辑实现合约。然而,如果代理已存在,插件会通过将现有代理链接到新的逻辑实现来协助升级过程。
- ProxyAdmin:这个管理组件专为 transparent proxies(透明代理)管理谁可以升级代理(只有 Transparent Upgradeable Proxies 使用 Proxy Admin)。
- Beacon Proxies(信标代理):Beacon Proxy Pattern 不会为代理分配单独的管理员地址。相反,单个信标(beacon)拥有一个所有者,该所有者负责为所有链接的代理更新逻辑实现。插件自动完成信标及代理的设置,并更新信标的逻辑实现。
- 插件设计用于同时兼容 Hardhat 和 Foundry。Hardhat 环境会保留详细的部署日志,而 Foundry 则侧重于使用 reference contracts(参考合约)来确保升级的安全性。
定义 Reference Contracts
与通过 JSON 自动跟踪逻辑实现合约及其版本的 Hardhat 的 OpenZeppelin Upgrade 插件不同,Foundry 插件要求开发者显式定义 Reference Contracts(参考合约)。
请看以下合约中的 NatSpec:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
/// @custom:oz-upgrades-from MyContractV1
contract MyContractV2 {
...
}
或
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
/// @custom:oz-upgrades-from contracts/MyContract.sol:MyContractV1
contract MyContractV2 {
...
}
该 NatSpec 指向了 reference contract,这是 Foundry 插件用来获取该参考合约引用的方式。
参考合约是将要被升级的合约,它作为基准来确保新的逻辑实现与原始的状态和布局相兼容。
总的来说,你可以将“参考合约”视为“先前的逻辑实现”。
插件中的 validateUpgrade 函数会检查新的逻辑实现合约是否与参考合约兼容。它要求设置 referenceContract 选项,或者新合约上必须存在 @custom:oz-upgrades-from <reference> 注释。
关于如何使用 Upgrades 工具中 validateUpgrade 函数的细节将在本指南稍后讨论。
在 Foundry 中本地测试智能合约升级
现在我们展示在本地部署和升级 Transparent Upgradeable Proxy 的步骤。稍后我们将展示如何在测试网上执行此操作。以下是我们将采取的高层次步骤:
- 部署代理和逻辑实现
ContractA:- 部署
ContractA并对其进行初始化。 - 这一步会自动设置好
ContractA、一个TransparentUpgradeableProxy以及一个Proxy Admin合约。 ContractA将作为此次升级过程的参考合约。
- 部署
- 升级到
ContractB:- 该工具会使用代理上的
upgradeAndCall方法将合约更新为ContractB。
- 该工具会使用代理上的
第 1 步:设置环境
首先创建一个新的项目目录并初始化 Foundry。打开终端并执行以下命令:
mkdir rareskills-foundry-upgrades && cd rareskills-foundry-upgrades
forge init
接下来,我们需要准备项目所需的基础文件。这些包括智能合约文件、测试文件以及依赖映射文件。
执行以下命令在你的项目目录中创建这些文件:
touch src/ContractA.sol && touch src/ContractB.sol && touch test/Upgrades.t.sol && touch remappings.txt
第 2 步:配置项目
项目初始化后,下一个任务是设置处理可升级合约所需的 OpenZeppelin 库。
按如下方式更新 remappings 文件:
forge remappings > remappings.txt
在终端中运行以下命令以安装所需的库。
forge install OpenZeppelin/openzeppelin-contracts-upgradeable --no-commit
forge install OpenZeppelin/openzeppelin-foundry-upgrades --no-commit
no-commit 标志避免了在使用上述命令之前必须提交当前代码库状态的麻烦。
接下来,我们需要确保项目知道在哪里可以找到 OpenZeppelin 文件。这通过配置我们之前创建的 remappings.txt 文件来完成。
打开 remappings.txt 并插入以下行:
@openzeppelin/contracts/=lib/openzeppelin-contracts-upgradeable/lib/openzeppelin-contracts/contracts/
@openzeppelin/contracts-upgradeable/=lib/openzeppelin-contracts-upgradeable/contracts/
如果 OpenZeppelin 映射已经存在于 remapping.txt 文件中,请将它们替换为上面的内容。
接下来,删除自动生成的 test/Counter.t.sol 测试和 src/Counter.sol:
rm test/Counter.t.sol
rm src/Counter.sol
最后,打开 foundry.toml 并添加以下配置:
[profile.default]
src = "src"
out = "out"
libs = ["node_modules", "lib"]
build_info = true
extra_output = ["storageLayout"]
ffi = true
ast = true
第 3 步:创建可升级合约
现在我们将创建两个智能合约 ContractA 和 ContractB,以演示升级过程。
从 ContractA.sol 开始。该合约包含一个公共变量 value,以及一个替代构造函数的 initialize 方法。
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.19;
import "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol";
contract ContractA is Initializable{
uint256 public value;
function initialize(uint256 _setValue) public initializer {
value = _setValue;
}
}
接下来,我们将创建 ContractB.sol 来展示从 ContractA 升级的路径。
ContractB 通过添加一个递增 value 的方法扩展了 ContractA 的功能。
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.19;
import "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol";
/// @custom:oz-upgrades-from ContractA
contract ContractB is Initializable {
uint256 public value;
function initialize(uint256 _setValue) public initializer {
value = _setValue;
}
function increaseValue() public {
value += 10;
}
}
通过使用 @custom:oz-upgrades-from ContractA 注释,我们指定了 ContractB 是 ContractA 的升级版本。
该注释不要求 ContractA 和 ContractB 在同一目录中。它通过名称来识别 ContractA(前提是该名称在项目中是唯一存在的),否则,需要使用完全限定名称(fully qualified name)。
如果没有此注释,插件将无法继续,并会显示类似以下的错误:
`The contract ${sourceContract.fullyQualifiedName} does not specify what contract it upgrades from. Add the \`@custom:oz-upgrades-from <REFERENCE_CONTRACT>\` annotation to the contract, or include the reference contract name when running the validate command or function.`
虽然我们在这个例子中同时准备了 ContractA 和 ContractB,但这并不意味着我们需要“预见” ContractA 未来的升级。为了本示例的方便,我们只是提前创建了 ContractB。
第 4 步:测试可升级功能
这一步涉及编译合约、将它们作为 Transparent Proxy Pattern 部署、执行升级,并验证合约的状态是否如预期更新。
准备测试环境
首先,导航到 test 文件夹,并将以下代码插入到 Upgrades.t.sol 文件中。
此设置用于测试从 ContractA 到 ContractB 的可升级性。
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.19;
import "forge-std/Test.sol";
import "openzeppelin-foundry-upgrades/Upgrades.sol";
import "../src/ContractA.sol";
import "../src/ContractB.sol";
contract UpgradesTest is Test {
// future code will go here
}
初始测试涉及两个主要操作:
- 使用透明代理部署具有初始值的
ContractA。 - 升级到
ContractB。 - 最后,调用
increaseValue来修改状态。
这是测试的代码。请阅读下方代码中的注释以了解工作流程:
function testTransparent() public {
// Deploy a transparent proxy with ContractA as the implementation and initialize it with 10
address proxy = Upgrades.deployTransparentProxy(
"ContractA.sol",
msg.sender,
abi.encodeCall(ContractA.initialize, (10))
);
// Get the instance of the contract
ContractA instance = ContractA(proxy);
// Get the implementation address of the proxy
address implAddrV1 = Upgrades.getImplementationAddress(proxy);
// Get the admin address of the proxy
address adminAddr = Upgrades.getAdminAddress(proxy);
// Ensure the admin address is valid
assertFalse(adminAddr == address(0));
// Log the initial value
console.log("----------------------------------");
console.log("Value before upgrade --> ", instance.value());
console.log("----------------------------------");
// Verify initial value is as expected
assertEq(instance.value(), 10);
// Upgrade the proxy to ContractB
Upgrades.upgradeProxy(proxy, "ContractB.sol", "", msg.sender);
// Get the new implementation address after upgrade
address implAddrV2 = Upgrades.getImplementationAddress(proxy);
// Verify admin address remains unchanged
assertEq(Upgrades.getAdminAddress(proxy), adminAddr);
// Verify implementation address has changed
assertFalse(implAddrV1 == implAddrV2);
// Invoke the increaseValue function separately
ContractB(address(instance)).increaseValue();
// Log and verify the updated value
console.log("----------------------------------");
console.log("Value after upgrade --> ", instance.value());
console.log("----------------------------------");
assertEq(instance.value(), 20);
}
总结来说,该工具正在执行以下操作:
- 通过透明代理部署
ContractA(使用Upgrades.deployTransparentProxy("ContractA.sol", msg.sender, abi.encodeCall(ContractA.initialize, (10)));)并用特定值initializeContractA。 - 升级代理由其使用
ContractB(使用Upgrades.upgradeProxy(proxy, "ContractB.sol", "", msg.sender);)。 - 验证更新后的值以及升级后管理员地址的连贯性。
执行测试
要运行测试,在终端中输入以下命令:
forge clean && forge test -vvv --ffi
你将看到类似于以下的输出:
pari@MacBook-Air rareskills-foundry-upgrades % forge clean && forge test --mt testTransparent -vvv
[⠢] Compiling...
[⠊] Compiling 54 files with 0.8.24
[⠆] Solc 0.8.24 finished in 4.94s
Compiler run successful!
Ran 1 test for test/Upgrades.t.sol:UpgradesTest
[PASS] testTransparent() (gas: 1355057)
Logs:
----------------------------------
Value before upgrade --> 10
----------------------------------
----------------------------------
Value after upgrade --> 20
----------------------------------
Suite result: ok. 1 passed; 0 failed; 0 skipped; finished in 1.43s (1.43s CPU time)
Ran 1 test suite in 5.47s (1.43s CPU time): 1 tests passed, 0 failed, 0 skipped (1 total tests)
在第二次运行测试之前,你可能需要运行 forge cache clean 和 forge clean。
Beacon Proxy 示例
现在我们演示如何将此插件与 Beacon Proxy Pattern 一起使用。我们将使用与上一个示例相同的逻辑实现合约 ContractA 和 ContractB。
测试概述
- 部署
ContractA作为 Beacon 的初始逻辑实现。 - 创建两个 beacon 代理,分别用不同的值进行初始化。记住,在 beacon 代理模式中,多个代理指向同一个逻辑实现,但它们拥有各自独立的状态。
- 使用
validateUpgrade函数将新实现(ContractB)与原始实现(ContractA)进行验证。 - 将信标的逻辑实现升级至
ContractB,从而同时升级两个代理。 - 在两个代理上测试新功能,以确保升级按预期应用了更改。
将此函数添加到 Upgrades.t.sol 文件以进行测试。注释中解释了工作流程:
import {IBeacon} from "@openzeppelin/contracts/proxy/beacon/IBeacon.sol";
import {Options} from "openzeppelin-foundry-upgrades/Upgrades.sol";
function testBeacon() public {
// Deploy a beacon with ContractA as the initial implementation
address beacon = Upgrades.deployBeacon("ContractA.sol", msg.sender);
// Get the initial implementation address of the beacon
address implAddrV1 = IBeacon(beacon).implementation();
// Deploy the first beacon proxy and initialize it
address proxy1 = Upgrades.deployBeaconProxy(beacon, abi.encodeCall(ContractA.initialize, 15));
ContractA instance1 = ContractA(proxy1);
// Deploy the second beacon proxy and initialize it
address proxy2 = Upgrades.deployBeaconProxy(beacon, abi.encodeCall(ContractA.initialize, 20));
ContractA instance2 = ContractA(proxy2);
// Check if both proxies point to the same beacon
assertEq(Upgrades.getBeaconAddress(proxy1), beacon);
assertEq(Upgrades.getBeaconAddress(proxy2), beacon);
console.log("----------------------------------");
console.log("Value before upgrade in Proxy 1 --> ", instance1.value());
console.log("Value before upgrade in Proxy 2 --> ", instance2.value());
console.log("----------------------------------");
// Validate the new implementation before upgrading
Options memory opts;
opts.referenceContract = "ContractA.sol";
Upgrades.validateUpgrade("ContractB.sol", opts);
// Upgrade the beacon to use ContractB
Upgrades.upgradeBeacon(beacon, "ContractB.sol", msg.sender);
// Get the new implementation address of the beacon after upgrade
address implAddrV2 = IBeacon(beacon).implementation();
// Activate the increaseValue function in both proxies
ContractB(address(instance1)).increaseValue();
ContractB(address(instance2)).increaseValue();
console.log("----------------------------------");
console.log("Value after upgrade in Proxy 1 --> ", instance1.value());
console.log("Value after upgrade in Proxy 2 --> ", instance2.value());
console.log("----------------------------------");
// Check if the values have been correctly increased
assertEq(instance1.value(), 25);
assertEq(instance2.value(), 30);
// Check if the implementation address has changed
assertFalse(implAddrV1 == implAddrV2);
}
Upgrades 插件支持的功能
该插件支持的功能不仅限于我们在上面两个示例中使用的功能。我们在下面提供 Upgrades 中其他功能的概述:
初始部署函数:
这些函数主要用于部署合约的初始版本以及设置它们的代理结构:
deployUUPSProxy(*string contractName, bytes initializerData, struct Options opts*): 使用给定合约作为逻辑实现来部署 UUPS 代理。deployTransparentProxy(*string contractName, address initialOwner, bytes initializerData, struct Options opts*): 使用给定合约作为逻辑实现来部署透明代理。deployBeaconProxy(*address beacon, bytes data, struct Options opts*): 使用给定的信标和调用数据部署 beacon 代理。deployBeacon(*string contractName, address initialOwner, struct Options opts*): 使用给定合约作为逻辑实现部署可升级信标。
验证和实现函数:
这些函数用于确保在升级期间逻辑实现的兼容性和安全性:
validateImplementation(*string contractName, struct Options opts*): 验证逻辑实现合约,但不进行部署。deployImplementation(*string contractName, struct Options opts*): 验证并部署逻辑实现合约,并返回其地址。validateUpgrade(*string contractName, struct Options opts*): 将新逻辑实现合约与参考合约进行比较验证,但不进行部署。prepareUpgrade(*string contractName, struct Options opts*): 将新逻辑实现合约与参考合约进行比较验证,部署新的逻辑实现合约并返回其地址。
升级函数:
这些函数用于管理和执行对已部署合约的升级:
upgradeProxy(*address proxy, string contractName, bytes data, struct Options opts*): 将代理升级为新逻辑实现合约。此函数在底层会调用validateUpgrade(),因此如果验证不成功,它将会失败。如果用户希望绕过某些验证,可以在 opts 参数中进行配置。upgradeBeacon(*address beacon, string contractName, struct Options opts*): 将信标升级为新逻辑实现合约。
其他函数:
getAdminAddress(*address proxy*): 从其 ERC1967 管理员 storage slot 中获取透明代理的管理员地址。
在 Sepolia Testnet 上部署和验证
本节将指导你完成部署 ContractA、使用透明代理将其升级为 ContractB,并在 Sepolia Explorer 上验证合约。
第 1 步:添加环境变量
首先,设置部署到测试网所需的配置:
- 创建一个
.env文件以存储敏感数据。 - 将
.env文件包含在你的.gitignore中,以防止在像 Github 这样的版本控制平台上泄露私钥或其他敏感信息。 - 使用你的特定数据填充
.env文件:
SEPOLIA_RPC_URL=your-sepolia-endpoint
PRIVATE_KEY=your-private-key
ETHERSCAN_API_KEY=your-etherscan-api
SENDER=your-EOA-address
配置详情:
SEPOLIA_RPC_URL:用于连接 Sepolia 网络的 RPC URL。PRIVATE_KEY:你的私钥,用于签署交易。确保此钱包拥有以太币以支付部署的 gas 费用。ETHERSCAN_API_KEY:你的 Etherscan API 密钥,用于合约验证。SENDER:发起部署交易的以太坊地址。
第 2 步:配置 foundry.toml 文件
更新 foundry.toml 文件以包含适用于 Sepolia Testnet 和 Etherscan 验证的设置:
[etherscan]
sepolia = { key = "${ETHERSCAN_API_KEY}" }
[rpc_endpoints]
sepolia = "${SEPOLIA_RPC_URL}"
此配置完成了两项任务:
- Etherscan Configuration(Etherscan 配置): 将 Etherscan API Key 与 Sepolia 网络链接,用于部署后的合约验证。
- RPC Endpoints(RPC 端点): 指定 Sepolia 网络的 RPC URL。
第 3 步:编写部署和升级脚本
导航到 script 文件夹并打开 Upgrades.s.sol 文件以插入部署和升级任务所需的代码。
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.19;
import {Script} from "forge-std/Script.sol";
import {ContractA} from '../src/ContractA.sol';
import {ContractB} from '../src/ContractB.sol';
import {Upgrades} from "openzeppelin-foundry-upgrades/Upgrades.sol";
contract UpgradesScript is Script {
function setUp() public {}
function run() public {
uint256 deployerPrivateKey = vm.envUint("PRIVATE_KEY");
vm.startBroadcast(deployerPrivateKey);
// Deploy `ContractA` as a transparent proxy using the Upgrades Plugin
address transparentProxy = Upgrades.deployTransparentProxy(
"ContractA.sol",
msg.sender,
abi.encodeCall(ContractA.initialize, 10)
);
}
}
在幕后,这将为我们部署代理(Proxy)、代理管理员(ProxyAdmin)以及 ContractA。
deployTransparentProxy 函数接受 3 个参数:
contractName(string):要部署逻辑实现的合约名称,例如“MyContract.sol”、“MyContract.sol:MyContract”或相对工件(artifact)路径。initialOwner(address):设置为 ProxyAdmin 合约所有者的地址,该合约与代理一起自动部署。initializerData(bytes):代理创建期间要执行的 initializer 函数的编码调用数据;如果不需要初始化,则留空。
该函数返回已部署代理的地址。
执行脚本
使用以下命令在 Sepolia 网络上部署和验证升级。
forge clean && forge script script/UpgradesScript.s.sol --rpc-url sepolia --private-key $PRIVATE_KEY --broadcast --verify --sender $SENDER
此命令清除以前的构建,执行脚本,并在 Sepolia Explorer 上验证合约。
执行后,你应该会看到表示合约成功部署和验证的输出:
ONCHAIN EXECUTION COMPLETE & SUCCESSFUL.
Total Paid: 0.00356888630073128 ETH (862220 gas * avg 4.139182924 gwei)
##
Start verification for (3) contracts
Start verifying contract `0x427186c574B5fA11cB9d796871861EF87c74Ad37` deployed on sepolia
Submitting verification for [src/ContractA.sol:ContractA] 0x427186c574B5fA11cB9d796871861EF87c74Ad37.
Submitted contract for verification:
Response: `OK`
GUID: `hf2dplvhjmjpj3nixun3kupamtsgmbacngfeygpm9p34mbzb3g`
URL: https://sepolia.etherscan.io/address/0x427186c574b5fa11cb9d796871861ef87c74ad37
Contract verification status:
Response: `NOTOK`
Details: `Pending in queue`
Contract verification status:
Response: `OK`
Details: `Pass - Verified`
Contract successfully verified
Start verifying contract `0xbA58580452Bc758C9a044584F6CEa468e5569a13` deployed on sepolia
Submitting verification for [lib/openzeppelin-contracts-upgradeable/lib/openzeppelin-contracts/contracts/proxy/transparent/TransparentUpgradeableProxy.sol:TransparentUpgradeableProxy] 0xbA58580452Bc758C9a044584F6CEa468e5569a13.
Submitted contract for verification:
Response: `OK`
GUID: `biaqcdgrhwjfu8d7b3n9jg6btmsjpktgd61rdx42n7ptttuvre`
URL: https://sepolia.etherscan.io/address/0xba58580452bc758c9a044584f6cea468e5569a13
Contract verification status:
Response: `NOTOK`
Details: `Pending in queue`
Contract verification status:
Response: `OK`
Details: `Pass - Verified`
Contract successfully verified
Start verifying contract `0x8bB6A51C24ad9b6bA276c2bf0380e5E8Ce31E866` deployed on sepolia
Submitting verification for [lib/openzeppelin-contracts-upgradeable/lib/openzeppelin-contracts/contracts/proxy/transparent/ProxyAdmin.sol:ProxyAdmin] 0x8bB6A51C24ad9b6bA276c2bf0380e5E8Ce31E866.
Submitted contract for verification:
Response: `OK`
GUID: `rfnvjnxa8j2rqxxgwtnx9if17rdlyky2nk9eixrmzjbp5pn4gy`
URL: https://sepolia.etherscan.io/address/0x8bb6a51c24ad9b6ba276c2bf0380e5e8ce31e866
Contract verification status:
Response: `NOTOK`
Details: `Pending in queue`
Contract verification status:
Response: `NOTOK`
Details: `Pending in queue`
Contract verification status:
Response: `NOTOK`
Details: `Already Verified`
Contract source code already verified
All (3) contracts were verified!
Transactions saved to: /Users/nest/rareskills/rareskills-foundry-upgrades/broadcast/UpgradesScript.s.sol/11155111/run-latest.json
Sensitive values saved to: /Users/nest/rareskills/rareskills-foundry-upgrades/cache/UpgradesScript.s.sol/11155111/run-latest.json
在此脚本执行期间,deployTransparentProxy 命令部署了 ContractA 以及一个 Transparent Upgradeable Proxy 和一个 Proxy Admin Contract。
可以在 Sepolia Explorer 上查看这些交易:
- ContractA 部署:https://sepolia.etherscan.io/tx/0x9dba6d8293629cb9557500d8645659de3127e75abcfa705b06e6cf379092a10e
- Transparent upgradeable Proxy:https://sepolia.etherscan.io/tx/0x9dba6d8293629cb9557500d8645659de3127e75abcfa705b06e6cf379092a10e
- Proxy Admin Contract:https://sepolia.etherscan.io/address/0xba58580452bc758c9a044584f6cea468e5569a13#code#F6#L1
升级合约
在升级到 ContractB 之前,我们将使用插件的 validateUpgrade 函数参照参考合约 ContractA 验证新的逻辑实现。
验证确认后,接下来我们将使用 upgradeProxy 函数进行升级。请阅读以下代码中的注释:
import {Options} from "openzeppelin-foundry-upgrades/Upgrades.sol";
function run() public {
uint256 deployerPrivateKey = vm.envUint("PRIVATE_KEY");
vm.startBroadcast(deployerPrivateKey);
// Specifying the address of the existing transparent proxy
address transparentProxy = 'your-transparent-proxy-address';
// Setting options for validating the upgrade
Options memory opts;
opts.referenceContract = "ContractA.sol";
// Validating the compatibility of the upgrade
Upgrades.validateUpgrade("ContractB.sol", opts);
// Upgrading to ContractB and attempting to increase the value
Upgrades.upgradeProxy(transparentProxy, "ContractB.sol", abi.encodeCall(ContractB.increaseValue, ()));
}
如果新合约的逻辑实现与参考合约不兼容,它会抛出以下错误:
revert: Upgrade safety validation failed:
同样,使用相同的脚本部署和升级 ContractB,并在 Explorer 上进行验证。

可以在此处查看升级交易。
OpenZeppelin Foundry 插件如何帮助防止升级问题
本节列出了特定于代理升级的潜在安全问题以及该工具如何防范这些问题。
1. Immutable(不可变)变量
在 Solidity 中,immutable 关键字允许在合约创建期间通过将变量的值直接嵌入到字节码中来永久设置变量。这迫使未来的部署对于构造函数使用完全相同的参数,以确保未来的部署拥有相同的字节码。由于这很难追踪,该插件不鼓励使用 immutable 变量。
为了允许在可升级合约中使用 immutable 变量,开发者可以使用 @custom:oz-upgrades-unsafe-allow 注释绕过此安全检查,如下所示。
contract ImmutableVar {
/// @custom:oz-upgrades-unsafe-allow state-variable-immutable
uint256 public immutable a;
/// @custom:oz-upgrades-unsafe-allow constructor
constructor(uint256 _a) {
a = _a;
}
}
这允许使用 immutable 变量,但需要谨慎管理以保持部署一致性。
2. Storage Layout(存储布局)
在更新智能合约时,保持一致的 storage layout(存储布局)至关重要。状态变量的顺序或类型的任何更改都可能导致数据损坏。
这意味着如果你的初始合约如下所示:
contract MyContract {
uint256 private x;
string private y;
}
那么你不能更改变量的类型:
contract MyContract {
string private x; // uint256 became a string
string private y;
}
或者更改声明它们的顺序:
contract MyContract {
string private y; // x and y switched places
uint256 private x;
}
如果你需要引入一个新变量,请确保始终在末尾进行:
contract MyContract {
uint256 private x;
string private y;
bytes private z;
}
使用本文开头的 ContractA 和 ContractB 示例,假设我们错误地向 ContractB 插入了一个状态变量,如下所示:

我们将从 OpenZeppelin 插件收到以下错误(我们使用的测试与包含 Transparent Upgradeable Proxy 的第一个示例相同):

3. 验证正确使用了 __gap
正如我们在关于 Storage Namespaces 的文章中所讨论的,使用 __gap 变量是一种策略,用于在插入新的状态变量时防止父合约移动子合约的状态变量。要正确使用 __gap 变量,在插入新变量时必须减小 gap 的大小。在以下示例中,开发人员插入了一个新的状态变量,却忘记了减小 __gap 的大小:

该工具捕捉到了这一点:

OpenZeppelin 工具并不强制使用 __gap。相反,如果 存在 gap 变量,该工具会检查升级是否遵循了 __gap 变量的预期使用方式,即保持状态变量对齐。
比 __gap 策略更稳健的替代方案是使用 Named Storage Layouts(命名存储布局),如我们在关于 Storage Namespaces 的教程中所述,我们将在下一节中提供相关的示例。
4. 验证正确遵循了 ERC-7201
使用我们从一开始就在使用的 ContractA 和 ContractB 示例,让我们修改合约以使用 ERC-7201。
注意以下更改:
value现在是一个 public 函数,而不再是一个 public 变量。value的底层存储已移动到MyStorage结构体内。- setter 函数现在必须与
MyStorage结构体进行交互(注意该结构体用于对变量进行分组,它实际上从未被初始化)。
这是调整为遵循 ERC-7201 的 ContractA:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.19;
import "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol";
contract ContractA is Initializable {
/// @custom:storage-location erc7201:ContractA.storage.MyStorage
struct MyStorage {
uint256 value;
}
// keccak256(abi.encode(uint256(keccak256("ContractA.storage.MyStorage")) - 1)) & ~bytes32(uint256(0xff));
bytes32 private constant MyStorageLocation = 0xd255ccbed1486709ef10c220c9b584c9ad5cacd00961bdfc2156c2c7f2e4fc00;
function _getMyStorage() private pure returns (MyStorage storage $) {
assembly {
$.slot := MyStorageLocation
}
}
function value() public view returns (uint256) {
MyStorage storage $ = _getMyStorage();
return $.value;
}
function initialize(uint256 _setValue) public initializer {
MyStorage storage $ = _getMyStorage();
$.value = _setValue;
}
}
以及 ContractB:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.19;
import "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol";
/// @custom:oz-upgrades-from ContractA
contract ContractB is Initializable {
/// @custom:storage-location erc7201:ContractA.storage.MyStorage
struct MyStorage {
uint256 value;
}
// keccak256(abi.encode(uint256(keccak256("ContractA.storage.MyStorage")) - 1)) & ~bytes32(uint256(0xff));
bytes32 private constant MyStorageLocation = 0xd255ccbed1486709ef10c220c9b584c9ad5cacd00961bdfc2156c2c7f2e4fc00;
function _getMyStorage() private pure returns (MyStorage storage $) {
assembly {
$.slot := MyStorageLocation
}
}
function value() public view returns (uint256) {
MyStorage storage $ = _getMyStorage();
return $.value;
}
function initialize(uint256 _setValue) public initializer {
MyStorage storage $ = _getMyStorage();
$.value = _setValue;
}
function increaseValue() public {
MyStorage storage $ = _getMyStorage();
$.value += 10;
}
}
假设我们在 ContractB 的 MyStorage 结构体中捣乱,将变量插入到了非末尾的位置:
/// @custom:storage-location erc7201:ContractA.storage.MyStorage
struct MyStorage {
uint256 badInsert; // this should not be here, it should be at the end
uint256 value;
}
有了上述更改,该工具将呈现以下错误:

该工具将防止的另一个问题是重命名 namespace(命名空间)插槽。假设我们将结构体上方的注释更改如下:
/// @custom:storage-location erc7201:ContractA.storage.MyStorage
/// @custom:storage-location erc7201:ContractA.storage.MyStorage2
现在我们收到以下错误:

在升级之间不应该删除 Namespaces。
未调用父初始化器无法被自动捕获
在以下示例中,ContractA 和 ContractB 都继承自 Base。然而,它们都没有调用父合约的 initializers(初始化器),这是一个 bug。由于理解一个函数是否充当 initializer 需要语义解释,因此该工具无法自动捕获此问题。开发人员或审计人员必须手动检查所有 initializers 是否被正确调用(如果变量被初始化为非零值,通常通过单元测试就可以轻松捕获此类问题)。
以下代码即使未调用 __Base_init 函数,也不会触发工具报错。

结论
本文详细介绍了如何使用 OpenZeppelin upgrade Foundry 插件、该插件如何用于单元测试和 Foundry 脚本,以及它如何简化部署和升级的多步骤流程。我们展示了如何配置部署环境,并提供了一些该工具如何自动捕获各种错误的示例。
作者与致谢
本文由 Pari Tomar 联合 RareSkills 编写。
感谢 OpenZeppelin 的 Eric Lau 对本文初稿提供的宝贵意见。
首发于 2024 年 8 月 28 日