现代软件的设计原则是“敏捷开发,迅速迭代”,功能升级或bug修复是所有软件系统都要面对的问题。甚至可以说软件质量在很大程度上依赖于升级和修补源代码的能力。当然Dapp(去中心化应用)也不例外,尤其Dapp一切都是透明的,这使得任何级别的bug都会被成倍的放大,因此可升级的智能合约成为所有Dapp的必然选择。
本文主要以openzeppelin为基础来阐述构建可升级智能合约的一般流程和注意事项。
openzeppelin通过在用户与智能合约中间加入一个代理来实现合约的透明升级,用户直接与代理交互,代理将用户的请求转发到实际合约,同时将合约的执行结果响应给用户。
如上图所示,升级时只需要让Proxy指向Implementation合约即可。
上图有如下三种类型合约:
该合约被称为逻辑合约,Dapp的所有逻辑都在该合约中完成,Proxy以delegatecall的形式调用该合约中的方法。
在介绍该合约前我们先考虑一个问题——我们如何调用Proxy本身的方法?比如Proxy与Implementation都有一个方法upgradeTo(address),那么当用户调用该方法时,Proxy是该调用其自身方法还是以delegatecall的形式调用Implementation?
OpenZeppelin是通过”透明代理“(transparent proxy )的模式来解决这个问题的。该模式通过发起调用的地址来决定如何调用方法。
假设Proxy有owner()和upgradeTo()方法,Implementation有owner()和transfer()方法,则不同用户发起调用时具体调用方法如下:
msg.sender | owner() | upgradeto() | transfer() |
---|---|---|---|
Owner |
returns proxy.owner() |
returns proxy.upgradeTo() |
fails |
Other |
returns Implementation.owner() |
fails |
returns Implementation.transfer() |
通过上面的讨论我们可以看出,部署Proxy合约的账户无法调用Implementation合约中的方法,为此OpenZeppelin用ProxyAdmin来管理部署Proxy,此时Proxy的部署者为ProxyAdmin,这样用户就不用担心本地账户无法调用Implementation的情况,当然OpenZeppelin也提供了专门的接口用于更改Proxy的管理者。
下面在hardhat本地节点,以自动售货机的合约升级为例来说明合约升级流程。
npx hardhat node
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.19;
import "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol";
// import "hardhat/console.sol";
contract VendingMachineV1 is Initializable {
// these state variables and their values
// will be preserved forever, regardless of upgrading
uint public numSodas;
address public owner;
function initialize(uint _numSodas) public initializer {
numSodas = _numSodas;
owner = msg.sender;
}
function purchaseSoda() public payable {
require(msg.value >= 1000 wei, "You must pay 1000 wei for a soda!");
numSodas--;
}
function withdrawProfits() public onlyOwner {
require(
address(this).balance > 0,
"Profits must be greater than 0 in order to withdraw!"
);
(bool sent, ) = owner.call{value: address(this).balance}("");
require(sent, "Failed to send ether");
}
function setNewOwner(address _newOwner) public onlyOwner {
owner = _newOwner;
}
modifier onlyOwner() {
require(msg.sender == owner, "Only owner can call this function.");
_;
}
}
const { ethers, upgrades } = require('hardhat');
async function main() {
const VendingMachineV1 = await ethers.getContractFactory('VendingMachineV1');
const proxy = await upgrades.deployProxy(VendingMachineV1, [100]);
await proxy.waitForDeployment();
const proxyAddr = await proxy.getAddress();
const implementationAddress = await upgrades.erc1967.getImplementationAddress(
proxyAddr
);
console.log('Proxy contract address: ' + proxyAddr);
console.log('Implementation contract address: ' + implementationAddress);
}
main();
执行部署脚本
npx hardhat run scripts/deployProxy.js --network localhost
部署脚本执行后会打印出代理地址和VendingMachineV1合约的地址,如下:
该版本较版本1添加了supplySoda方法用于补充库存,为了缩短篇幅这处省略版本1中相同的代码部分。
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.19;
import "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol";
contract VendingMachineV2 is Initializable {
//....
//....旧版本相同的代码
function supplySoda(uint _num) public {
require(_num > 0);
numSodas += _num;
}
}
为了更清晰的说明升级过程,此处我们在hardhat终端进行升级。
npx hardhat console --network localhost
从图中我们可以看到,Proxy指向的Implementation的地址与前面部署版本1时输出的地址一致。
注:此处箭头1是Proxy的地址,箭头2是Implementation的地址
由上图可知,更新后Proxy已指向了新版本的地址(见红框)。执行新版本的方法补充库存后结果如下:
通过OpenZeppelin编写可升级合约时,在合约中不能有构造函数,一般将初始化的操作放在一个initialize的普通函数中(当然可以是任意的函数名,此时只需要调用部署API时指定该函数即可),升级过程中OpenZeppelin组件会主动执行该函数。
构造函数与普通函数最大的区别是,构造函数只在部署时执行一次,而普通函数可以多次执行,因此需要向initialize函数加上initializer修饰符,该modifer只允许该函数执行一次。
在执行上面升级版本2时,我们发现更新版本时虽然我们新版本传入了构造函数的参数但新版本的initialize并未执行。
upgrades.upgradeProxy('0xe7f1725E7734CE288F8367e1Bb143E90bb3F0512', V2, {opts:{constructorArgs:[300]}});
在编写合约的新版本时,无论是由于新特性还是由于bug修复,都有一个额外的限制需要遵守:您不能更改合约状态变量声明的顺序,也不能更改它们的类型。具体原因看这里。
一般在写可升级合约时我们需要预留一些空间,以允许该合约的未来版本在不影响子合约的存储布局的情况下使用这些槽。通用的做法是在基础合约中预先定义固定大小的uint256数组(由于EVM以槽为单位执行操作,而槽大小为32字节),该数组一般定义为__gap或以__gap_为前缀,以便OpenZeppelin升级可以识别该数组为预留空间,当版本升级需要新加变量时可以释放该数组的空间,如下:
//升级前合约
contract Base {
uint256 base1;
uint256[50] __gap;
}
contract Child is Base {
uint256 child;
}
//升级后合约
contract Base {
uint256 base1;
uint256 base2;
uint256[49] __gap;
}
或者
contract Base {
uint256 base1;
uint128 base2a;
uint128 base2b;
uint256[49] __gap;
}
其实质是在proxy的fallback函数中添加如下逻辑。
// This code is for "illustration" purposes. To implement this functionality in production it
// is recommended to use the `Proxy` contract from the `@openzeppelin/contracts` library.
// https://github.com/OpenZeppelin/openzeppelin-contracts/blob/v4.8.2/contracts/proxy/Proxy.sol
assembly {
// (1) copy incoming call data
calldatacopy(0, 0, calldatasize())
// (2) forward call to logic contract
let result := delegatecall(gas(), implementation, 0, calldatasize(), 0, 0)
// (3) retrieve return data
returndatacopy(0, 0, returndatasize())
// (4) forward return data back to caller
switch result
case 0 {
revert(0, returndatasize())
}
default {
return(0, returndatasize())
}
}
通过Unstructured Storage Proxies来解决
Proxy Upgrade Pattern - OpenZeppelin Docs
Writing Upgradeable Contracts - OpenZeppelin Docs