基于Hardhat和Openzeppelin开发可升级合约
在本章我将开始介绍和演示 基于 Openzeppelin 的可升级合约解决方案
简介
根据设计,智能合约是不可变的。但随着新的客户需求和产品设计的升级迭代,合约也需要升级。
Openzeppelin 的基础可升级合约解决方案是将合约数据与逻辑分离。
代理合约(Proxy) 负责转发交易到逻辑合约,并保存 合约数据
逻辑合约(Logic)负责实现功能逻辑
升级时,只需要重新部署新版本的逻辑合约,并将代理合约中的逻辑合约实例指向新版本逻辑合约实例即可
可升级合约的原理-DelegateCall
第三方库
Hardhat 关于Hardhat的安装和介绍, 参考我的另一篇文章<<简介智能合约开发框架-Hardhat>>
Upgrades Plugins
安装 openzeppelin 库
# 安装脚手架工具 ➜ npm install --save-dev @openzeppelin/hardhat-upgrades # 安装 openzepplein 可升级合约库, 我们在开发合约时会引用这里的合约文件 ➜ npm install @openzeppelin/contracts-upgradeable
编码
修改合约文件
基于上一章中的项目进行修改, 所以这里还是修改 MyContract.sol 合约文件
初始化函数改造
这里我们需要引用Openzeppelin的可升级合约库@openzeppelin/contracts-upgradeable
// SPDX-License-Identifier: UNLICENSED pragma solidity ^0.8.0; // 引用@openzeppelin/contracts-upgradeable 可升级合约库 import "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol"; // 我们的合约需要继承Initializable contract MyContract is Initializable { int storageValue; // modifier(修饰器) initializer 可以确保initialize只会被调用一次 function initialize(int initValue) public initializer { storageValue = initValue; } function setValue(int newValue) public { storageValue = newValue + 1; } function getValue() public view returns (int) { return storageValue; } }
这里我们做了两件事:
引入可升级合约库
改造初始化函数
需要重点说明了的是,在可升级合约中,不能使用合约的构造函数 constructor()
《基于 Openzeppelin 的可升级合约解决方案的注意事项》
编译
如果刚刚按照第一章操作的读者,请按两次 Ctrl + C 退出控制台再输入编译命令编译
# 编译 ➜ npx hardhat compile Compiled 3 Solidity files successfully # 提示成功编译了3个 solidity文件,因为MyContract 继承的父合约也要编译 # 因为我们继承了 Initializable,以及 Initializable 所继承的 AddressUpgradeable
这里编译通过,我们继续下一步 部署和交互测试
部署与调试
修改注册可升级插件
修改hardhat.config.js文件,添加@openzeppelin/hardhat-upgrades的引用
require("@nomiclabs/hardhat-waffle"); require('@openzeppelin/hardhat-upgrades');
修改部署脚本文件
还是修改 deploy.js 脚本文件
// 引用 可升级插件 const { ethers, upgrades } = require("hardhat"); async function main() { // 获取 MyContract合约 const MyContract = await ethers.getContractFactory("MyContract"); // 部署, 传入初始化 storageValue 的值 const myContract = await upgrades.deployProxy( MyContract, // 要部署的合约 [666], // 初始化参数 { initializer: 'initialize' } // 指定初始化函数名称 ); // 等待 MyContract合约部署完成 await myContract.deployed(); // 输出 MyContract合约地址 console.log("MyContract deployed to:", myContract.address); } // We recommend this pattern to be able to use async/await everywhere // and properly handle errors. main() .then(() => process.exit(0)) .catch((error) => { console.error(error); process.exit(1); });
部署
我们还是部署到本地节点,确保本地节点启动了,如果没有输入npx hardhat node命令启动,并新开控制台程序操作部署
下面是部署命令
在合约部署之前,可以先删除.openzeppelin下的unknow文件,并在部署后记录保存可升级合约相关地址
➜ npx hardhat run --network localhost scripts/deploy.js MyContract deployed to: 0xDc64a140Aa3E981100a9becA4E685f962f0cF6C9
合约部署成功!
但这里我们只得到了一个地址0xDc64a140Aa3E981100a9becA4E685f962f0cF6C9,但前面我们说可升级合约有逻辑合约和代理合约,为什么这里只有一个合约地址?
下面我们来解密
真实的合约地址
打开项目目录找到 .openzeppelin目录下的unknown-31337.json文件
这里我对unknown-31337.json里的数据进行了标注,大家可以打开图片查看细节
这里可以看到有三个地址,可我前面只说到了两个,因为在我们实践操作前来说管理员(admin)合约会比较抽象,所以现在再说会比较合适
管理员(admin)合约,这里的管理员其实主要指的是管理代理合约与逻辑合约的关系,当我们操作合约升级时其实是修改代理合约中逻辑合约的实例指向新版本逻辑合约
现在我们能看到三个地址:
管理员合约:0xCf7Ed3AccA5a467e9e704C703E8D87F634fB0Fc9
代理合约:0xDc64a140Aa3E981100a9becA4E685f962f0cF6C9
逻辑合约:0x9fE46736679d2D9a65F0992F2272dE9f3c7fa6e0 MyContract合约的真实地址
接下来我们通过控制台测试刚刚部署的合约
本地节点上的合约交互
刚上一章的步骤一样
➜ npx hardhat console --network localhost Welcome to Node.js v16.15.1. Type ".help" for more information. > const MyContract = await ethers.getContractFactory("MyContract") undefined > const myContract = await MyContract.attach("0xDc64a140Aa3E981100a9becA4E685f962f0cF6C9") undefined > await myContract.getValue() BigNumber { value: "666" } > await myContract.setValue(111) { hash: '0x806e46660e1eacf6c967fa1d6a75414bae230b34a208ae4f855e420d47961319', type: 2, accessList: [], blockHash: '0x731b09b68c08232cc9bc2ed0d18a336ec1c6bf6f9c964c9ad97717000d94418e', blockNumber: 6, transactionIndex: 0, confirmations: 1, from: '0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266', gasPrice: BigNumber { value: "455945547" }, maxPriorityFeePerGas: BigNumber { value: "0" }, maxFeePerGas: BigNumber { value: "577056082" }, gasLimit: BigNumber { value: "34472" }, to: '0xDc64a140Aa3E981100a9becA4E685f962f0cF6C9', value: BigNumber { value: "0" }, nonce: 5, data: '0x5093dc7d000000000000000000000000000000000000000000000000000000000000006f', r: '0xf31ab81921058ccb193d6c39165c185febdf0c4437117bc6cad4f33f5c27ab1d', s: '0x1c53f8eea9967c097e2f9fa4aa02d8ab94107350a7bfb0751c11c981bdf0f123', v: 0, creates: null, chainId: 31337, wait: [Function (anonymous)] } > await myContract.getValue() BigNumber { value: "112" }
这里可以看到,我们在实例化MyContract合约时,指定的地址是代理合约地址0xDc64a140Aa3E981100a9becA4E685f962f0cF6C9,而不是逻辑合约(真正的MyContract合约)地址0x9fE46736679d2D9a65F0992F2272dE9f3c7fa6e0 ,并且交易都成功执行了。
这是因为代理合约会把我们的交易转发给MyContract合约,最后把结果返还给我们。
问题
上一章我提到过,我想通过 setValue 方法设置新的 storageValue 值,但是我们在合约里的代码多写了一个 +1(虽然是为了演示故意为之)。所以我要修复合约代码里的bug,删除这部分代码
那么接下来我们着手开始编写新版本合约代码,并完成合约升级
升级新版本
新版本合约代码
我们在contracts目录下新建MyContractV2.sol合约文件,把MyContract.sol的代码拷贝过来,修改合约名,修复错误代码,并添加一个新的string型的状态变量和操作方法
像这样
// SPDX-License-Identifier: UNLICENSED pragma solidity ^0.8.0; import "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol"; // 我们的合约需要继承Initializable contract MyContractV2 is Initializable { int storageValue; // 新的状态变量 string storageStr; function initialize(int initValue) public initializer { storageValue = initValue; } function setValue(int newValue) public { storageValue = newValue; } function getValue() public view returns (int) { return storageValue; } // 设置 storageStr function setStr(string memory newStr) public { storageStr = newStr; } // 查询 storageStr function getStr() public view returns (string memory) { return storageStr; } }
编译
➜ npx hardhat compile Compiled 1 Solidity file successfully # 因为上一次编译文件已经把前面写的合约编译过了,所以这次只编译新增的 MyContractV2
部署
修改部署脚本文件
部署前,我们还需要在scripts目录下新建upgrade-MyContract.js升级脚本文件,并添加下面的代码
const { ethers, upgrades } = require("hardhat"); // 代理合约地址 const myContractProxyAddr = "0xDc64a140Aa3E981100a9becA4E685f962f0cF6C9" async function main() { const MyContractV2 = await ethers.getContractFactory("MyContractV2"); // 升级 const myContractV2 = await upgrades.upgradeProxy(myContractProxyAddr, MyContractV2); console.log("myContractV2 upgraded"); } main() .then(() => process.exit(0)) .catch((error) => { console.error(error); process.exit(1); });
部署
# 运行 pgrade-MyContract.js ➜ npx hardhat run --network localhost scripts/upgrade-MyContract.js myContractV2 upgraded
部署完成
合约地址
再次打开项目目录找到 .openzeppelin目录下的unknown-31337.json文件,我们可以看到impls里多了一个合约地址
这里的0x0165878A594ca255338adfa4d48449f69242Eb8F就是刚刚成功部署的逻辑合约(MyContractV2)
合约交互
操作跟前面一样,但这次我们几个点:
合约升级后,以前的数据是否正常?
升级后,bug是否已经修复
新添加的string型状态变量与相关操作方法是否可以正常调用
那么开始
➜ npx hardhat console --network localhost Welcome to Node.js v16.15.1. Type ".help" for more information. > const MyContractV2 = await ethers.getContractFactory("MyContractV2") undefined # # 这里实例化合约时,我们还是只想相同的代理合约地址 > const myContractV2 = await MyContractV2.attach("0xDc64a140Aa3E981100a9becA4E685f962f0cF6C9") undefined # # 这里验证了第1点,升级后数据依然保留 > await myContractV2.getValue() BigNumber { value: "112" } # # 再次调用 setValue 方法 > await myContractV2.setValue(111) { hash: '0x3e40f270713a705e403e89d4436b9a058a0463290ce0d903367f37efcc103b5c', type: 2, accessList: [], blockHash: '0xccb9f43f973204a8ebf96e563269bc89a2d9f37131653a1ad5f22bfc78d649f1', blockNumber: 9, transactionIndex: 0, confirmations: 1, from: '0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266', gasPrice: BigNumber { value: "307113676" }, maxPriorityFeePerGas: BigNumber { value: "0" }, maxFeePerGas: BigNumber { value: "388690746" }, gasLimit: BigNumber { value: "34239" }, to: '0xDc64a140Aa3E981100a9becA4E685f962f0cF6C9', value: BigNumber { value: "0" }, nonce: 8, data: '0x5093dc7d000000000000000000000000000000000000000000000000000000000000006f', r: '0xd074afac4ae37c931d2c270e9123e02e9291e0cf514d1640e67ce13d077628a1', s: '0x3c47a9528e95e67a325ffb79c6a52da3523b2268bc3dbff0e3589a0f843b293f', v: 1, creates: null, chainId: 31337, wait: [Function (anonymous)] } # # 验证了第2点,之前的bug已经修复 > await myContractV2.getValue() BigNumber { value: "111" } > await myContractV2.getStr() '' # # 调用新版本添加的方法 setStr > await myContractV2.setStr("i am lyon") { hash: '0x017db24bdc29a1b56bc02a197f7347ae2186508aed8eeec206c810c52f2d358c', type: 2, accessList: [], blockHash: '0x5d66d6259200a092eea01a448fbbbf43b000773433aa6f75658cf75210960a09', blockNumber: 10, transactionIndex: 0, confirmations: 1, from: '0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266', gasPrice: BigNumber { value: "268811416" }, maxPriorityFeePerGas: BigNumber { value: "0" }, maxFeePerGas: BigNumber { value: "340214448" }, gasLimit: BigNumber { value: "52779" }, to: '0xDc64a140Aa3E981100a9becA4E685f962f0cF6C9', value: BigNumber { value: "0" }, nonce: 9, data: '0x191347df000000000000000000000000000000000000000000000000000000000000002000000000000000000000000000000000000000000000000000000000000000096920616d206c796f6e0000000000000000000000000000000000000000000000', r: '0x2519d536c6a0fb26e2fca71738247aedb9f9e14cf6b66f9cea67b3fe999f9837', s: '0x2aad3b415db1d9078d8766bb79f6f0fc98eb8ce5d8d4c9688c5e63016183d899', v: 0, creates: null, chainId: 31337, wait: [Function (anonymous)] } # # 这里验证了第3点,新添加的方法和状态变量都访问正常 > await myContractV2.getStr() 'i am lyon'
到这里我们已经成功的完成了合约升级, 并且通过控制台验证的方式验证了升级后的合约符合预期。
总结
我相信刚接触合约升级的读者肯定会有很多的疑问,我会在后面的文章来继续介绍合约升级的相关知识。
可升级合约的原理-DelegateCall
参考
来源 https://blog.csdn.net/Lyon_Nee/article/details/125515837
可升级合约的原理-DelegateCall https://blog.csdn.net/Lyon_Nee/article/details/125537829