目前主流有三种合约升级方法
本次采用 transparent 方式,具体实现思路即,引入一个代理合约 Proxy(蓝色),用户仅与这个代理合约进行交互,由代理合约去与业务合约进行交互,因此在业务合约发生变化(升级)的时候,用户无感,并且历史数据也能够保留下来,如下图所示:
既然业务合约可以随意切换,那用户数据就只能存储在代理合约中了,在实际进行业务处理时,数据读写都是从代理合约来的,即数据与逻辑分离,其实现的核心便是 delegatecall 关键字。在此之前,先对 solidity 提供的三个合约调用方法:call、staticdall、delegatecall 进行对比说明
delegatecall 特点:
1、从 Target Contract 角度来看:msg.sender 是 user,而不是 Proxy,即 Proxy 对 user 的请求进行了透传;
2、在 Target Contract 被调用时,使用的是 Proxy 的上下文,即执行合约带来的状态变化会存在 Proxy 中,而不是 Target Contract 之中
(注:由于 Transparent 模式升级时,implementation 和 proxy 不用相互关心彼此的 storage 数据,因此这种模式被称为:unstructed storage)
在 solidity 中,状态变量存储在 slot 中,slot 可以理解为 key-value 存储空间,evm 为每个合约代码提供了最多 2^256个 slot,每个 slot 可以最多存储 32 字节数据。状态变量一般是从 slot 0 开始进行存储的,在使用 delegatecall 的时候,由于需要在 Proxy 的 slot 中存储目标合约中指定的数据结构,此时如果 proxy 的 storage 布局与目标合约的 storage 布局不相同,那么就会出现存储冲突(Storage collisioin)的问题。
slot 0 分别存储的是逻辑合约地址 _imp 和管理员地址 _owner,出现存储冲突。
在代理合约 Proxy 中,一共需要指定两个状态变量:
因此,如果不进行特殊处理,则一定会出现存储 slot 冲突,我们可以将 Proxy 中的默认 slot 留出来,不要占用,而是在代理合约使用指定的 slot 来存储逻辑合约 _imp 和 admin 地址,这个解决方案也叫 EIP-1967
# implementation slot 生成规则:
bytes32(uint256(keccak256('eip1967.proxy.beacon')) - 1),
# admin slot 生成规则:
bytes32(uint256(keccak256('eip1967.proxy.admin')) - 1)),
1 在升级的合约中,如果有新变量的添加,那么新的状态变量只能在原始合约状态末尾依次往后添加,否则也会导致状态变量布局不一致,出现存储冲突;
2 如果一个合约定义为可升级的,那么这个合约不能有构造函数,需要使用initialize 函数来代替初始化工作。因为我们需要将部署时的数据存储在 Proxy 合约中,如果提供了构造函数,这些数据就会错误地写在逻辑合约中。
首先在 node_modules 目录下安装合约升级地标准库,以使用初始化函数
npm i @openzeppelin/contracts-upgradeable
创建 WorldCupV1.sol,在原有 WorldCup 基础上修改代码
//1. 导入标准包
import "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol";
//2. 继承
contract WorldCupV1 is Initializable {
//3. 将构造函数替换为初始化函数 constructor(uint256 _deadline)
function initialize(uint256 _deadline) public initializer {
admin = msg.sender;
require(
_deadline > block.timestamp,
"WorldCupLottery: invalid deadline!"
);
deadline = _deadline;
}
}
再创建升级后的合约 WorldCupV2.sol,修改如下:
event ChangeDeadline(uint256 _prev, uint256 _curr);
uint256 changeCount
// 1. 增加函数,支持修改deadline
function changeDeadline(uint256 _newDeadline) external {
require(_newDeadline > block.timestamp, "invalid timestamp!");
// 2.增加新事件
emit ChangeDeadline(deadline, _newDeadline);
// 4.状态变量
changeCount++;
deadline = _newDeadline;
}
安装升级插件,在配置文件中导入
$ npm install --save-dev @openzeppelin/hardhat-upgrades
// hardhat.config.js
require('@openzeppelin/hardhat-upgrades');
编写升级脚本,创建 scripts/deployAndUpgrade.ts:
const { ethers, upgrades } = require("hardhat");
async function main() {
const TWO_WEEKS_IN_SECS = 14 * 24 * 60 * 60;
const timestamp = Math.floor(Date.now() / 1000)
const deadline = timestamp + TWO_WEEKS_IN_SECS;
console.log('deadline:', deadline)
// Deploying
const WorldCupv1 = await ethers.getContractFactory("WorldCupV1");
const instance = await upgrades.deployProxy(WorldCupv1, [deadline]);
await instance.deployed();
console.log("WorldCupV1 address:", instance.address);
console.log("deadline1:", await instance.deadline())
console.log('ready to upgrade to V2...');
// Upgrading
const WorldCupV2 = await ethers.getContractFactory("WorldCupV2");
const upgraded = await upgrades.upgradeProxy(instance.address, WorldCupV2);
console.log("WorldCupV2 address:", upgraded.address);
await upgraded.changeDeadline(deadline + 100)
console.log("deadline2:", await upgraded.deadline())
}
main();
部署升级合约
npx hardhat run scripts/upgrade/deployAndUpgrade.ts --network goerli
我们还没 verify 业务合约 WorldCupV1 和 WorldCupV2,然后与当前的代理合约 Proxy 关联起来,我们通过 internal Txns 可以找到 WorldCupV2 的合约地址:
都对其进行 verify,并查看数据,发现都是空的
找到代理合约-> More Options -> Is this a proxy? -> Verify -> Save
然后再回到当前页面刷新后,页面上多了两个按钮:Read as Proxy 和 Write as Proxy 如图:
我们最终暴露给用户的地址就是这个代理合约,用户的所有操作都相当于在读写着两个新方法,这两个方法会被 Proxy 传递到逻辑合约中,并把执行结果返回到代理合约中
通过hardhat-upgrade包执行部署后,一共会自动部署三个合约:
当我们使用脚本执行合约升级的时候,此时内部交互为: