在我们进行合约开发时有一个痛点是,升级部署到链上后不能再更改,但如果了解Solidity比较深的小伙伴就知道,Solidity有个delegate方法,可以实现通过代理合约调用逻辑合约,我们的数据存储在代理合约中,执行的逻辑在逻辑合约中,我们想要升级合约时只需要部署新的逻辑合约即可。
在我们进行合约开发时有一个痛点是,升级部署到链上后不能再更改,但如果了解
Solidity
比较深的小伙伴就知道,Solidity
有个delegate
方法,可以实现通过代理合约调用逻辑合约,我们的数据存储在代理合约中,执行的逻辑在逻辑合约中,我们想要升级合约时只需要部署新的逻辑合约即可。具体执行逻辑如下图:
// SPDX-License-Identifier: MIT
// wtf.academy
pragma solidity ^0.8.4;
// 简单的可升级合约,管理员可以通过升级函数更改逻辑合约地址,从而改变合约的逻辑。
// 教学演示用,不要用在生产环境
contract SimpleUpgrade {
address public implementation; // 逻辑合约地址
address public admin; // admin地址
string public words; // 字符串,可以通过逻辑合约的函数改变
// 构造函数,初始化admin和逻辑合约地址
constructor(address _implementation){
admin = msg.sender;
implementation = _implementation;
}
// fallback函数,将调用委托给逻辑合约
fallback() external payable {
(bool success, bytes memory data) = implementation.delegatecall(msg.data);
}
// 升级函数,改变逻辑合约地址,只能由admin调用
function upgrade(address newImplementation) external {
require(msg.sender == admin);
implementation = newImplementation;
}
}
// 旧逻辑合约
contract Logic1 {
// 状态变量和proxy合约一致,防止插槽冲突
address public implementation;
address public admin;
string public words; // 字符串,可以通过逻辑合约的函数改变
// 改变proxy中状态变量,选择器: 0xc2985578
function foo() public{
words = "old";
}
}
// 新逻辑合约
contract Logic2 {
// 状态变量和proxy合约一致,防止插槽冲突
address public implementation;
address public admin;
string public words; // 字符串,可以通过逻辑合约的函数改变
// 改变proxy中状态变量,选择器:0xc2985578
function foo() public{
words = "new";
}
}
代码中包含了3
个合约:
SimpleUpgrade
: 代理合约Logic1
: 旧逻辑合约Logic2
: 新逻辑合约SimpleUpgrade
代理合约包含3
个变量:
implementation
: 逻辑合约地址admin
: 合约管理员地址words
: 字符串,通过调用逻辑合约函数来改变也包含了3
个函数:
admin
和implementation
地址fallback
函数: 委托函数,会将函数调用委托给逻辑合约执行,需要通过函数选择器calldata
来调用upgrade
函数: 升级函数,只能由admin
调用,改变逻辑合约地址旧逻辑合约中变量和代理合约保持一致(防止函数执行时插槽错误),通过代理合约调用时改变的状态变量是代理合约中的,有一个函数foo
,将代理合约中的words
值改为old
。
和旧逻辑合约逻辑一直,foo
将代理合约中的words
改为new
。
Remix
中首先部署旧逻辑合约(Logic1)
和新逻辑合约(Logic2)
代理合约(SimpleUpgrade)
,构造函数中填入旧逻辑合约(Logic1)
的地址代理合约
去调用旧逻辑合约
的foo
函数,需要通过低级调用的方式填入函数签名在calldata
中,这里填入c2985578
函数签名可以通过https://abi.hashex.org/
,来生成
代理合约
中的words
就被改成了old
upgrade
函数,填入新逻辑合约
地址,实现逻辑合约的升级calldata
中调用新逻辑合约的foo
函数,就可以看到代理合约中的words
改变为了new
到此,我们就完成了可升级合约的开发和部署,但可升级合约还有可能产生选择器冲突问题。
大家可以看到我们上面填的两个foo
函数的函数签名其实是foo
哈希后取的前 4 个字节,4 个字节这个范围其实很少,两个不同的函数很有可能造成hash
的前 4 个字节一样,这就造成了选择器冲突。
如果选择器冲突出现在同一个合约中,那么合约是无法编译成功的,但是可升级合约会部署两个合约,比如代理合约的升级函数和逻辑合约中其中一个函数有选择器冲突,那么管理人在调用逻辑合约中的函数就可能将代理合约升级成黑洞合约,有严重的安全问题。
解决的方法一般有两种:
// SPDX-License-Identifier: MIT
// wtf.academy
pragma solidity ^0.8.4;
// 透明可升级合约的教学代码,不要用于生产。
contract TransparentProxy {
address implementation; // logic合约地址
address admin; // 管理员
string public words; // 字符串,可以通过逻辑合约的函数改变
// 构造函数,初始化admin和逻辑合约地址
constructor(address _implementation){
admin = msg.sender;
implementation = _implementation;
}
// fallback函数,将调用委托给逻辑合约
// 不能被admin调用,避免选择器冲突引发意外
fallback() external payable {
require(msg.sender != admin);
(bool success, bytes memory data) = implementation.delegatecall(msg.data);
}
// 升级函数,改变逻辑合约地址,只能由admin调用
function upgrade(address newImplementation) external {
if (msg.sender != admin) revert();
implementation = newImplementation;
}
}
// 旧逻辑合约
contract Logic1 {
// 状态变量和proxy合约一致,防止插槽冲突
address public implementation;
address public admin;
string public words; // 字符串,可以通过逻辑合约的函数改变
// 改变proxy中状态变量,选择器: 0xc2985578
function foo() public{
words = "old";
}
}
// 新逻辑合约
contract Logic2 {
// 状态变量和proxy合约一致,防止插槽冲突
address public implementation;
address public admin;
string public words; // 字符串,可以通过逻辑合约的函数改变
// 改变proxy中状态变量,选择器:0xc2985578
function foo() public{
words = "new";
}
}
透明代理是通过限制管理员的权限,管理员只能调用代理合约中的升级函数,不能调用逻辑合约中函数,其他用户只能调用逻辑合约中的函数不能调用代理合约的升级函数来解决选择器冲突问题。
// SPDX-License-Identifier: MIT
// wtf.academy
pragma solidity ^0.8.4;
contract UUPSProxy {
address public implementation; // 逻辑合约地址
address public admin; // admin地址
string public words; // 字符串,可以通过逻辑合约的函数改变
// 构造函数,初始化admin和逻辑合约地址
constructor(address _implementation){
admin = msg.sender;
implementation = _implementation;
}
// fallback函数,将调用委托给逻辑合约
fallback() external payable {
(bool success, bytes memory data) = implementation.delegatecall(msg.data);
}
}
// UUPS逻辑合约(升级函数写在逻辑合约内)
contract UUPS1{
// 状态变量和proxy合约一致,防止插槽冲突
address public implementation;
address public admin;
string public words; // 字符串,可以通过逻辑合约的函数改变
// 改变proxy中状态变量,选择器: 0xc2985578
function foo() public{
words = "old";
}
// 升级函数,改变逻辑合约地址,只能由admin调用。选择器:0x0900f010
// UUPS中,逻辑函数中必须包含升级函数,不然就不能再升级了。
function upgrade(address newImplementation) external {
require(msg.sender == admin);
implementation = newImplementation;
}
}
// 新的UUPS逻辑合约
contract UUPS2{
// 状态变量和proxy合约一致,防止插槽冲突
address public implementation;
address public admin;
string public words; // 字符串,可以通过逻辑合约的函数改变
// 改变proxy中状态变量,选择器: 0xc2985578
function foo() public{
words = "new";
}
// 升级函数,改变逻辑合约地址,只能由admin调用。选择器:0x0900f010
// UUPS中,逻辑函数中必须包含升级函数,不然就不能再升级了。
function upgrade(address newImplementation) external {
require(msg.sender == admin);
implementation = newImplementation;
}
}
通用可升级代理(UUPS)是通过把升级函数也放在逻辑合约中,代理合约只存储状态变量和调用逻辑合约中的所有函数(升级函数和其他逻辑函数)来解决选择器冲突问题,因为通过代理合约来调用逻辑合约的升级函数时,改变的也是代理合约中存储的逻辑合约的地址,这样我们其实升级也是没有任何问题的。
Hardhat
+OpenZeppelin
开发生产环境的可升级合约用上面的办法开发的可升级合约,虽然可以实现可升级功能,但对于一些安全问题没有很好的处理,所以我们一般在实际项目开发中会使用如Hardhat
、OpenZeppelin
等工具来开发可升级合约。
Hardhat
项目npm init
npm install --save-dev hardhat
npx hardhat init
$ npx hardhat init
888 888 888 888 888
888 888 888 888 888
888 888 888 888 888
8888888888 8888b. 888d888 .d88888 88888b. 8888b. 888888
888 888 "88b 888P" d88" 888 888 "88b "88b 888
888 888 .d888888 888 888 888 888 888 .d888888 888
888 888 888 888 888 Y88b 888 888 888 888 888 Y88b.
888 888 "Y888888 888 "Y88888 888 888 "Y888888 "Y888
👷 Welcome to Hardhat v2.22.2 👷
? What do you want to do? …
> Create a JavaScript project
Create a TypeScript project
Create a TypeScript project (with Viem)
Create an empty hardhat.config.js
Quit
OpenZeppelin
可升级合约的hardhat
插件依赖和合约依赖npm install --save-dev @openzeppelin/hardhat-upgrades
npm install --save-dev @openzeppelin/contracts-upgradeable
contracts/Box.sol
:
// contracts/Box.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
import "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol";
contract Box is Initializable {
uint256 private _value;
function initialize(uint256 value) public initializer {
_value = value;
}
// Emitted when the stored value changes
event ValueChanged(uint256 value);
// Stores a new value in the contract
function store(uint256 value) public {
_value = value;
emit ValueChanged(value);
}
// Reads the last stored value
function retrieve() public view returns (uint256) {
return _value;
}
}
合约很简单,就是存储了一个_value
值,并通过store
来修改这个值,并通过retrieve
来读取这个值。
其中有个很关键的initialize
函数,这是合约的初始化函数,在以前我们写构造函数是通过constructor
,但在OpenZeppelin
可升级合约中需要使用initialize
函数。并通过继承Initializable
合约,并在initialize
函数上添加initializer
函数修饰器来确保这个初始化函数只能执行一次。
script/deploy.js
const { ethers, upgrades } = require("hardhat");
async function main() {
const Box = await ethers.getContractFactory("Box");
console.log("Deploying Box...");
const box = await upgrades.deployProxy(Box, [70]);
console.log("Box deployed to:", box.target);
}
main();
其中deployProxy
的第二参数为初始化函数需要的参数,通过数组的形式传进去,然后通过运行下面命令来部署,--network
为我自己添加的本地ganache
网络,也可以改成其他网络或者不写,不写会部署到hardhat
的本地测试网络。
npx hardhat run script/deploy.js --network ganache
hardhat
提供的console
来测试npx hardhat console --network ganache
Welcome to Node.js v12.22.1.
Type ".help" for more information.
> const Box = await ethers.getContractFactory('Box');
undefined
> const box = await Box.attach('0xC707173c04105676B7AbadEA745A1cc04f3A5b3A');
undefined
> (await box.retrieve()).toString();
'70'
其中Box.attach
函数需要填入我们上面部署好的Box
合约地址。
BoxV2
合约// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
import "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol";
contract BoxV2 is Initializable {
uint256 private _value;
function initialize(uint256 value) public initializer {
_value = value;
}
// Emitted when the stored value changes
event ValueChanged(uint256 value);
// Stores a new value in the contract
function store(uint256 value) public {
_value = value;
emit ValueChanged(value);
}
// Reads the last stored value
function retrieve() public view returns (uint256) {
return _value;
}
// Increments the stored value by 1
function increment() public {
_value = _value + 1;
emit ValueChanged(_value);
}
}
在BoxV2
合约中我们新增了一个increment
函数,用来增加_value
的值。
script/upgrade.js
const { ethers, upgrades } = require("hardhat");
async function main() {
const BoxV2 = await ethers.getContractFactory("BoxV2");
console.log("Upgrading Box...");
await upgrades.upgradeProxy(
"0xC707173c04105676B7AbadEA745A1cc04f3A5b3A",
BoxV2
);
console.log("Box upgraded");
}
main();
其中upgradeProxy
函数需要填入我们上面部署好的Box
合约地址,并在命令行执行下面命令来升级
npx hardhat run .\scripts\upgrade.js --network ganache
最后在通过hardhat
的console
来测试发现就多了一个increment
函数了。
npx hardhat console --network ganache
Welcome to Node.js v12.22.1.
Type ".help" for more information.
> const BoxV2 = await ethers.getContractFactory('BoxV2');
undefined
> const box = await BoxV2.attach('0xC707173c04105676B7AbadEA745A1cc04f3A5b3A');
undefined
> await box.increment();
...
> (await box.retrieve()).toString();
'71'
至此我们就通过
Hardhat
和OpenZeppelin
来实现了一个生产环境可用的可升级合约,大家开发自己的可升级合约时就可以参考这个形式开发即可。
如果觉得我的文章对您有用,请随意打赏。你的支持将鼓励我继续创作!