## 0x00:介绍 最近要对使用了UUPS可升级模式的合约进行测试部署,打算先用个小demo熟悉一下。openzeppelin为Foundry框架实现了Upgrades插件,目的是使得**透明模式**、**信标模式**、**UUPS模式** 3种可升级代理模式的合约,可以更加方便、安全地测试部署。 本篇文章,会先执行一个demo。然后介绍Upgrades插件的相关函数,最后说说一些注意点。目前Upgrades插件还处于完善中,遇到一些意外报错,请查阅官方文档或仓库,了解最新内容。 <br> **Foundry Upgrades相关资料:** 官方仓库:https://github.com/OpenZeppelin/openzeppelin-foundry-upgrades 官方文档:https://docs.openzeppelin.com/upgrades-plugins/foundry-upgrades RareSkills的教程文章:https://www.rareskills.io/post/openzeppelin-foundry-upgrades <br> ## 0x01:Demo Demo的话,直接基于RareSkills文章中的修改了。RareSkills文章中,讲了透明模式和信标模式,但没有UUPS模式,所以也算是一个补充了。 <br> 初始化一个项目: ```shell $ forge init upgrade_demo ``` 下载依赖(这里用的OZ版本为v5): ```shell $ forge install foundry-rs/forge-std $ forge install OpenZeppelin/openzeppelin-foundry-upgrades $ forge install OpenZeppelin/openzeppelin-contracts-upgradeable ``` 下载完依赖后,在项目根目录(`upgrade_demo`)下创建`remappings.txt`,添加如下路径解析: ``` @openzeppelin/contracts/=lib/openzeppelin-contracts-upgradeable/lib/openzeppelin-contracts/contracts/ @openzeppelin/contracts-upgradeable/=lib/openzeppelin-contracts-upgradeable/contracts/ ``` 修改foundry.toml,配置为: ```toml [profile.default] ffi = true ast = true build_info = true extra_output = ["storageLayout"] ``` 把`/script`、`/src`、`/test`中的默认文件删掉。添加合约文件`/src/ContractA.sol`、 `/src/ContractB.sol` 和测试文件`/test/Upgrades.t.sol`。 <br> ContractA会作为一开始的实现合约,在后续会升级为ContractB,作为新的实现合约。而Upgrades.t.sol则编写测试代码,模拟这一过程。 文件结构: ``` ├── foundry.toml ├── lib │   ├── forge-std │   ├── openzeppelin-contracts-upgradeable │   └── openzeppelin-foundry-upgrades ├── README.md ├── remappings.txt ├── script ├── src │   ├── ContractA.sol │   ├── ContractB.sol └── test └── Upgrades.t.sol ``` <br> **ContractA.sol** ```solidity // SPDX-License-Identifier: MIT pragma solidity ^0.8.19; import "@openzeppelin/contracts-upgradeable/access/OwnableUpgradeable.sol"; import "@openzeppelin/contracts-upgradeable/proxy/utils/UUPSUpgradeable.sol"; contract ContractA is OwnableUpgradeable, UUPSUpgradeable { uint256 public value; function initialize(uint256 _setValue) public initializer { __Ownable_init(msg.sender); // 设置调用者为 owner __UUPSUpgradeable_init(); // 初始化 UUPS value = _setValue; } // 需要重写(这里添加了修饰符onlyOwner,表示只有owner才能升级合约) function _authorizeUpgrade(address newImplementation) internal override onlyOwner {} } ``` <br> **ContractB.sol** ```solidity // SPDX-License-Identifier: MIT pragma solidity ^0.8.19; import "@openzeppelin/contracts-upgradeable/access/OwnableUpgradeable.sol"; import "@openzeppelin/contracts-upgradeable/proxy/utils/UUPSUpgradeable.sol"; /// @custom:oz-upgrades-from ContractA contract ContractB is OwnableUpgradeable, UUPSUpgradeable { uint256 public value; function initialize(uint256 _setValue) public initializer { __Ownable_init(msg.sender); // 设置调用者为 owner __UUPSUpgradeable_init(); // 初始化 UUPS value = _setValue; } function _authorizeUpgrade(address newImplementation) internal override onlyOwner {} function increaseValue() public { // 升级,添加了个函数 value += 10; } } ``` `/// @custom:oz-upgrades-from ContractA`,这个文档注释在使用Upgrades插件时,是必要的,为了告诉Upgrades插件**ContractB**是想升级**ContractA**。 <br> **Upgrades.t.sol** ```solidity // 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 { function testUUPS() public { address proxy = Upgrades.deployUUPSProxy( "ContractA.sol", abi.encodeCall(ContractA.initialize, (10)) ); ContractA aProxy = ContractA(proxy); address implAddrV1 = Upgrades.getImplementationAddress(proxy); address adminAddr = aProxy.owner(); console.log("------------Before Upgrade-------------"); console.log("proxy address: ", proxy); console.log("imple address: ", implAddrV1); console.log("admin address: ", adminAddr); console.log("Value = ", aProxy.value()); console.log("---------------------------------------"); Upgrades.upgradeProxy( proxy, "ContractB.sol", "" // without calling any additional function ); ContractB bProxy = ContractB(proxy); address implAddrV2 = Upgrades.getImplementationAddress(proxy); adminAddr = bProxy.owner(); bProxy.increaseValue(); console.log("----------------After------------------"); console.log("proxy address: ", proxy); console.log("imple address: ", implAddrV2); console.log("admin address: ", adminAddr); console.log("Value = ", bProxy.value()); console.log("---------------------------------------"); } } ``` 测试代码我就不用断言来判断了,直接打印出来,直观一点。 <br> 执行测试: ```shell $ forge clean && forge test --match-test testUUPS -vvv --ffi ``` ![image-20250316104336656](https://hackmd.io/_uploads/B19zrHE2yx.png) 看打印结果,算是成功部署并升级了。 但在实际过程中,遇到了一些坑,后面会说说一些需要注意的点。 <br> ## 0x02:Upgrades中与UUPS相关的函数 Upgrades.t.sol中的一些函数还没做解释,这里先来介绍一下。 <br> ```solidity deployUUPSProxy(contractName, initializerData, opts) deployUUPSProxy(contractName, initializerData) ``` 这两个函数是在最开始时调用的,用于部署实现合约和代理合约。内部具体流程为: 1. 部署实现合约,由“contractName”指定; 2. 部署代理合约,并将实现合约地址设为上一步中部署的实现合约; 3. 通过代理合约**委托调用**实现合约中的initizlize函数,进行初始化; (opts参数暂未了解) <br> 升级函数: ```solidity upgradeProxy(proxy, contractName, data) upgradeProxy(proxy, contractName, data, opts) upgradeProxy(proxy, contractName, data, opts, tryCaller) upgradeProxy(proxy, contractName, data, tryCaller) ``` 将"proxy"代理的实现合约升级为合约"contractName"。这里的data和上面的initializerData作用是一样的,是新的实现合约的调用数据,通常是调用initialize函数。至于`tryCaller`是指定哪个账户去调用升级,通常是合约管理员。 <br> ``` getAdminAddress(proxy) getImplementationAddress(proxy) ``` 从函数名即可得知,分别用于获取代理合约的admin和实现合约地址。 <br> ## 0x03:proxy的Admin 我们在实现UUPS可升级模式的代理合约时,通常是使用openzeppelin的[ERC1967Proxy.sol](https://github.com/OpenZeppelin/openzeppelin-contracts/blob/master/contracts/proxy/ERC1967/ERC1967Proxy.sol)。代理合约需要存储一个admin数据,根据ERC-1967规范进行随机化。其存储**slot**为: ![image-20250315160836978](https://hackmd.io/_uploads/ryBBHBN2ye.png) 与该slot配套的,有`changeAmdin(newAdmin)`和`getAdmin()`函数,用于设置和获取该slot的值,即相对于设置和获取admin。 <br> 同时值得注意的是,代理合约**ERC1967Proxy.sol**在部署、执行构造函数时,是默认没有对admin进行设置的: ![image-20250316145911939](https://hackmd.io/_uploads/ryWwSHN2kg.png) 如果我们是另外写个`MyProxy`合约去继承`ERC1976Proxy`作为代理合约,那么就可以在`MyProxy`的构造函数中调用`changeAdmin`函数来设置admin。 但在使用Foundry的upgrades插件,在执行`deployUUPSProxy`函数部署代理时,是直接部署一个ERC1976Proxy.sol。代码段为: ![image-20250316150541135](https://hackmd.io/_uploads/Byv5HrNnye.png) 这意味着,但我们使用Upgrades插件时,无法通过常规方法(changeAdmin函数)去初始化。至于非常规的方法,可以直接在实现合约初始化(执行initialize)时,通过内嵌汇编直接更改admin对应slot的值。 <br> ## 0x04:与ownable库的owner区分 另外,出于习惯或方便,我们经常使用ownable库,对应到其升级实现,即`OwnableUpgradeable`。 但`OwnableUpgradeable`中,owner数据是根据ERC-7201规范存储的,其存储的**slot**为: ![image-20250315162327191](https://hackmd.io/_uploads/SJqYHr4hkx.png) 这与代理合约中的`ADMIN_SLOT`的存储位置是不同的,应该区别开来。 如果在一开始是对`ADMIN_SLOT`设置管理员,那么检查权限时,得是检查`ADMIN_SLOT`,而不是`OwnableStorageLocation`。相反同理,需要对应上。 <br> 而Upgrades插件有个函数:`getAdminAddress(proxy)`,但是在Upgrades.t.sol中我并没有用到。这是因为`getAdminAddress(proxy)`函数获取的是`ADMIN_SLOT`的值,而我们合约中是使用了`OwnableUpgradeable`的数据。 ![image-20250316152014925](https://hackmd.io/_uploads/H1QTBH4h1l.png) <br> ## 0x05:区别Test合约地址和调用者地址 看如下这个测试代码,你能发现有什么问题吗? ```solidity // 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 { function testUUPS() public { address proxy = Upgrades.deployUUPSProxy( "ContractA.sol", abi.encodeCall(ContractA.initialize, (10)) ); Upgrades.upgradeProxy( proxy, "ContractB.sol", "", msg.sender ); } } ``` <br> 会在升级时,显示权限不通过: ![image-20250316152509929](https://hackmd.io/_uploads/SyU1UHE3kx.png) <br> 这里有个需要注意的概念是,测试合约`UpgradesTest`也是一个合约,也有一个地址。当我们执行测试时,可以理解为,我们是用一个EOA账户来调用该测试合约执行。 对于测试代码,在执行`deployUUPSProxy`时,是UpgradesTest合约来部署合约。也就是说:在设置owner为`msg.sender`时,这个`msg.sender`是UgradesTest合约的地址。 而在`upgradeProxy`升级时,却指定了`msg.sender`(调用UpgradesTest合约的EOA账户)来调用,但EOA账户地址是不同于UpgradesTest合约的地址,所以权限验证不通过。 <br> 可以在demo的测试代码中,加入如下逻辑验证一下: ```solidity console.log("-------------------------------------"); console.log("Upgrades Address: ", address(this)); console.log("MsgSender Address: ", msg.sender); console.log("-------------------------------------"); ``` ![image-20250316153751659](https://hackmd.io/_uploads/HJblISV2Jx.png)