Dapp全栈开发:合约升级
想必大家都知道区块链的特点,即:数据公开、可追溯、不可篡改,这个在比特币身上为人津津乐道。但是到了以太坊上,由于支持编写智能合约,即程序,那么数据不可篡改就变成了一个棘手的问题:
因此,如果一个合约是可以动态升级的,可以在用户无感知的情况下修复bug、拓展功能似乎就变成了迫切的需要。
好消息是经过多年的发展,业界已经为我们提供了解决方案,即使用代理模式完成可升级合约,目前主流有三种升级方法,
我们本节聚焦在transparent方式(第一种),这是最主流的升级方案。
对于普通的合约,一般是用户直接与业务逻辑交互,如下图所示:
// 简化表达
User ---- tx ----------> Implementation_v0
此时,如果重新部署Logic合约,那么地址会发生变化,用户可以感知到,而且Logic中的用户数据也会发生变化。
可升级合约的实现思路是:引入一个代理合约Proxy(蓝色),用户User仅与这个代理合约进行交互,由代理合约去与Loigc合约进行交互,因此在Logic合约发生变化(升级)的时候,用户User无感,并且历史数据也能够保留下来,如下图所示:
// 简化表达
User ---- tx ---> Proxy ----------> Implementation_v0
|
------------> Implementation_v1
既然业务合约(Logic1和Logic2)可以随意的进行替换,那么说明数据与逻辑一定是分离的,那数据存储在哪里呢?你可能已经猜到了,数据一定是存储在Proxy中的,即业务逻辑执行后,将数据写入到proxy中,大致如下:
用户调用代理合约,代理合约调用逻辑合约,并将数据最终写入到Proxy的存储空间中,因此逻辑合约可以随时被替换。
上图中,我们见到了delegatecall关键字,这是代理合约的核心,为了能够说清楚细节,我们需要先回顾一下solidity提供的三个合约调用方法:call、staticdall、delegatecall,对比他们之间的异同。
上图中,图一使用了call和staticcall,图二使用了delegatecall,具体区别如下:
call:主要是进行常规的合约调用,比如进行合约向普通EOA进行转账时,语法为:to.call{value: value}(""),此时目标合约中的msg.sender是调用者,即Caller Contract;
staticcall: 与call类似,但是它不会修改合约状态,单纯调用计算而已,原理同上;(不常用)
delegatecall:这个是专门为代理合约Proxy准备的,作用是,帮助用户User来调用Target Contract合约,但是有以下特点:(图二)
从Target Contract角度来看:msg.sender是user,而不是Proxy,即Proxy对user的请求进行了透传;
在Target Contract被调用时,使用的是Proxy的上下文,即执行合约带来的状态变化会存在Proxy中,而不是Target Contract之中
正是由于delegatecall的这两个特性,才使得代理合约能够实现,因为这使得从目标合约的角度来看,所有的操作都是在修改用户的数据,并且存储在了Proxy中。
(注,由于Transparent模式升级时,implementation和proxy不用相互关心彼此的storage数据,因此这种模式被称为:unstructed storage)
delegatecall案例如下:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.13;
contract Implementation {
// NOTE: storage layout must be the same as contract A
uint public num;
address public sender;
uint public value;
function setVars(uint _num) public payable {
num = _num;
sender = msg.sender;
value = msg.value;
}
}
// 注意:执行后,Proxy中的sender值为EOA的地址,而不是A合约的地址 (调用链EOA-> Proxy::setVars -> Implementation::setVars)
contract Proxy {
uint public num;
address public sender;
uint public value;
function setVars(address _impl, uint _num) public payable {
// Proxy's storage is set, Implementation is not modified.
(bool success, bytes memory data) = _impl.delegatecall(
abi.encodeWithSignature("setVars(uint256)", _num)
);
}
}
在solidity中,状态变量存储在slot中,slot可以理解为key-value存储空间,evm为每个合约代码提供了最多2^256个slot,每个slot可以最多存储32字节数据。(key->value, 32bytes -> 32bytes)
状态变量一般是从slot 0开始进行存储的,在使用delegatecall的时候,由于需要在Proxy
的slot中存储目标合约
中指定的数据结构,此时如果proxy的storage布局与目标合约的storage布局不相同,那么就会出现存储冲突(Storage collisioin)的问题,即:目标合约在Proxy合约中修改了错误的位置。
|Proxy |Implementation |
|--------------------------|-------------------------|
|address _implementation |address _owner | <=== Storage collision!
|... |mapping _balances |
| |uint256 _supply |
| |... |
如图所示,由于在Proyx中需要存储逻辑合约的地址,而在逻辑合约中不用存储,所以此时两个合约的storage布局不同,两者的slot 0分别存储的是 逻辑合约地址_imp 和管理员地址,因此当逻辑合约想修改owner时,会错误的修改了_imp地址。
在代理合约Proxy中,一共需要指定两个状态变量:
因此,如果不进行特殊处理,则一定会出现存储slot冲突,我们要做的是:将Proxy中的默认slot留出来,不要占用,而是在代理合约使用指定的slot来中存储逻辑合约_imp
和admin地址,如何存储,业界对此有标准,EIP-1967
|Proxy |Implementation |
|--------------------------|-------------------------|
|... |address _owner |
|... |mapping _balances |
|... |uint256 _supply |
|... |... |
|... | |
|... | |
|... | |
|... | |
|address _implementation | | <=== Randomized slot.
|... | |
|... | |
存储逻辑合约地址的位置为:
# implementation slot 生成规则:
bytes32(uint256(keccak256('eip1967.proxy.beacon')) - 1),
# 存储位置:
0xa3f0ad74e5423aebfd80d3ef4346578335a9a72aeaee59ff6cb3582b35133d50
# admin slot 生成规则:
bytes32(uint256(keccak256('eip1967.proxy.admin')) - 1)),
# 存储位置:
0xb53127684a568b3173ae13b9f8a6016e243e63b6e8ee1178d6a717850b5d6103
当proxy将两个状态变量存储在特定位置后,由于将默认slot的空间留给了业务合约,因此相当于storage布局与逻辑合约一致了,冲突解决!
按照上述方式进行storage布局,我们解决了proxy和implementation之间的存储冲突问题,但是在执行的时候,Implementation0 和implementation1之间还是可能会存在storage collision问题,为何?
因为如果在新合约Implementation1中,改变了Implementation0中原始状态变量的顺序,那么就会出现存储冲突问题,如下图
|Implementation_v0 |Implementation_v1 |
|--------------------|-------------------------|
|address _owner |address _lastContributor | <=== Storage collision!
|mapping _balances |address _owner |
|uint256 _supply |mapping _balances |
|... |uint256 _supply |
| |... |
Correct storage preservation:
|Implementation_v0 |Implementation_v1 |
|--------------------|-------------------------|
|address _owner |address _owner |
|mapping _balances |mapping _balances |
|uint256 _supply |uint256 _supply |
|... |address _lastContributor | <=== Storage extension. #修改了原来
| |... |
所以在升级的合约Implementation1中,如果有新变量的添加,那么新的状态变量只能添加在原始合约状态末尾依次往后添加,切记切记,否则升级失败!
如果一个合约定义为可升级的,那么这个合约需要将构造函数移除(如有),并且使用initialize函数来代替初始化工作。原因为:我们需要将部署时的数据存储在Proxy合约中,如果提供了构造函数,这些数据就会错误的写在了逻辑合约中。
说了这么多,我们测试一下升级,我们使用openzeppelin标准库来完成合约升级,并且使用hardhat-upgrade来进行集成。
为了实现合约升级,我们需要改写一下原来的Worldcup代码,将构造函数去掉,增加初始化函数
# 安装升级版本的合约,我们需要使用标准的初始化函数
$ npm i @openzeppelin/contracts-upgradeable
修改代码:
//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,与V1相比,增加了代码,包括:一个函数,一个状态变量,一个事件,具体如下:
// 1. 增加函数,支持修改deadline
function changeDeadline(uint256 _newDeadline) external {
require(_newDeadline > block.timestamp, "invalid timestamp!");
// 2.增加新事件
emit ChangeDeadline(deadline: _newDeadline);
// 4.状态变量
changeCount++;
deadline = _newDeadline;
}
合约编写完毕,确保编译通过!
npm run compile
$ Successfully generated 86 typings!
$ Compiled 24 Solidity files successfully
# 升级插件,在部署脚本中使用
$ npm install --save-dev @openzeppelin/hardhat-upgrades
在配置文件中导入:
// hardhat.config.js
require('@openzeppelin/hardhat-upgrades');
contracts/scripts/upgrade/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了,所以我们直接能看到代理合约的代码
如果你点击WriteContract按钮,会发现这不是我们的业务逻辑提供的方法
但是我们的数据如何查看?答案是:我们需要手动verify我们的业务合约:WorldCupV1和WorldCupV2,然后与当前的代理合约Proxy关联起来,我们通过internal Txns可以找到WorldCupV2的合约地址:0x4cb210f91b6d95978d1cf055ddf88701c57c01f0
对其进行verify,并查看数据,发现都是空的。
npx hardhat verify 0x4cb210f91b6d95978d1cf055ddf88701c57c01f0 --network goerli
能走到这里说明你已经是个高手了,接下来是最后一步,我们即将代理合约与逻辑合约关联起来,操作为:找到代理合约-> More Options -> Is this a proxy? -> Verify -> Save
此时代理合约会自动根据我们的设置,自动去关联逻辑合约
再次回到代理合约页面,你会惊奇的发现,页面上多了两个按钮:Read as Proxy 和 Write as Proxy,查看数据,发现正好是我们设置的值,因此升级成功!
我们最终暴露给用户的地址就是这个代理合约,用户的所有操作都相当于在读写着两个新方法,这两个方法会被Proxy传递到逻辑合约中,并把执行结果返回到代理合约中,这就是delegatecall的力量!
本节我们系统的介绍了solidity中最主流的的代理方式:Transparent,代理合约使用delegatecall将用户的请求透传给业务合约,从而保证用户在无感知的情况下完成合约的升级。
合约升级带来好处显而易见,但是同时也意味着合约可以被随意的篡改或者做坏事,但是这就不是单纯的技术能够解决的问题了,由于升级操作只能有管理员来进行,所以将权力交给社区,由Dao组织来管理可能是一个比较好的选择。
加V入群:Adugii,公众号:阿杜在新加坡,一起抱团拥抱web3,下期见!
关于作者:国内第一批区块链布道者;2017年开始专注于区块链教育(btc, eth, fabric),目前base新加坡,专注海外defi,dex,元宇宙等业务方向。
如果觉得我的文章对您有用,请随意打赏。你的支持将鼓励我继续创作!