概述
你可以前往博客获得更好地阅读体验和更多文章
在以太坊智能合约中,很长时间都保持着“一次部署,永不修改”的情况。但随着智能合约的逐渐发展,出现了诸如修复BUG、增加特性、修复漏洞等需要修改智能合约的需求,我们非常希望可以编写可升级的智能合约。
经过智能合约开发的不断努力和solidity
语言的创新,编写可升级的智能合约成为显示本文主要介绍在智能合约部署过程中,我们可以通过多种方式编写可升级的智能合约。由于该方面的标准较多,我们无法在一篇内解释所有的方法,所以此篇仅解释了在以太坊历史上属于较为早期的方案,名单如下:
- Eternal Storage
- EIP-897 Proxy
- EIP-1822 UUPS
本教程所使用的代码均可在github仓库。
Eternal Storage
该方法由openzeppelin提出,方法最早的思想来源于此博客。
注意该方法不能实现真正的合约升级,且适用范围有限
该方法是为了解决合约重新部署后,原始合约内的数据消失的问题,基本思路是将合约的逻辑部分与数据存储分离,当我们需要重新部署合约时,将新合约的数据来源指向数据存储合约。从该方法的思路看出,这种方法的适用范围非常小,故而本节内容主要是为了让读者熟悉以太坊智能合约互相调用和多合约部署等基础知识。
存储智能合约
由于在上一篇文章或者CSDN内,我们已经完整介绍了foundry
环境的搭建,所以此文中我们将省略一部分对foundry框架的具体解释。
首先创建一个完整的项目:
forge init upgradeContract
在src\EternalStorage
文件夹下创建专用于数据存储的合约EternalStorage.sol
,该合约主要用于数据存储。在合约内写入以下内容:
// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.13;
contract EternalStorage {
mapping(bytes32 => uint256) public UIntStorage;
function getUIntValue(bytes32 record) public view returns (uint256) {
return UIntStorage[record];
}
function setUIntValue(bytes32 record, uint256 value) public {
UIntStorage[record] = value;
}
}
上述代码逻辑较为简单,最关键的是该合约维护了两个用于数据存储的映射,并提供了对外接口获取或设置映射值,对于使用Java等面向对象程序员来说这是一种常见的操作。
该代码内是在本系列教程内第一次出现view
,所以在此处,我们对其进行解释。view
关键词的作用是使函数可以读取但不能改变链上变量。
虽然合约内容较为简单,但我们仍提倡进行合约测试。由于我们在此项目内没有使用传统的项目框架,所以我们不能使用上一个教程内提到的forge remappings > remappings.txt
生成映射文件避免报错。我们需要在项目根目录下创建.vscode\settings.json
文件,写入以下内容:
{
"solidity.packageDefaultDependenciesContractsDirectory": "src",
"solidity.packageDefaultDependenciesDirectory": "lib"
}
在test/EternalStorage/EternalStorage.t.sol
文件内写入测试代码:
// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.13;
import "forge-std/Test.sol";
import "../../src/EternalStorage/EternalStorage.sol";
contract ContractTest is Test {
EternalStorage private storageEth;
function setUp() public {
storageEth = new EternalStorage();
}
function testGetIntValue() public {
storageEth.setUIntValue(keccak256('votes'), 1);
uint256 intValue = storageEth.getUIntValue(keccak256('votes'));
assertEq(intValue, 1);
}
}
该合约内出现了keccak256
函数,该函数的作用是获得信息的摘要值,使用的算法名称为keccak256
(SHA3算法),具体可参见维基百科。如果想进一步了解该函数,建议阅读《图解密码学技术》第7章。
运行forge test
命令,发现所有测试均可通过,我们将进入下一阶段,编写调用数据存储存储智能合约的功能性合约。
功能智能合约
该合约的主要作用使进行投票并获得投票后的结果,为了尽可能减少文章长度,此处给出的代码仅用了表达思想,而没有考虑太多的实用性。在src/EternalStorage/voteFirst.sol
写入下述代码:
// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.13;
error GetValueFail();
contract VoteFirst {
address eternalStorage;
constructor(address _eternalStorage) {
eternalStorage = _eternalStorage;
}
function getNumberOfVotes() public returns (uint256) {
(bool success, bytes memory data) = eternalStorage.call(
abi.encodeWithSignature("getUIntValue(bytes32)", keccak256("votes"))
);
if (!success) {
revert GetValueFail();
}
return abi.decode(data, (uint256));
}
function vote() public {
uint256 voteNum = getNumberOfVotes() + 1;
(bool success, ) = eternalStorage.call(
abi.encodeWithSignature(
"setUIntValue(bytes32,uint256)",
keccak256("votes"),
voteNum
)
);
if (!success) {
revert("Call Error");
}
}
}
该合约使用call
函数,该方法存在风险,但仅需要知道对方的地址和函数名即可运行,更加方便。call
通过二进制编码调用目标地址内的函数。为了获取指定函数的二进制编码,此处使用了abi.encodeWithSignature
函数,正如代码所示,此函数第一个参数是函数的名称(包含数据类型),其他参数为函数所需的具体参数。读者可以在可以阅读WTF Solidity 第22讲 Call获得更多信息。
由于此合约需要在
EternalStorage
环境下运行所以使用了call
而非delegatecall
。两者更加具体的区别可以参考WTFSolidity 第23讲 delegatecall
值得注意的是,由于在此合约内使用了call
函数,导致存储合约接收到的msg.value
不再是用户的地址而是VoteFirst
合约的地址,如果希望在VoteFirst
合约内加入有关用户地址的代码,请在VoteFirst
内将msg.value
直接作为参数传递给VoteFirst
而不是在EternalStorage
合约内使用msg.value
属性。下图很好的说明了这一点:
此图来自WTFSolidity 第23讲 delegatecall
在此处,我们编写一个简单的测试函数(test/EternalStorage/StoragteUpdate.t.sol
):
// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.13;
import "forge-std/Test.sol";
import "../../src/EternalStorage/EternalStorage.sol";
import "../../src/EternalStorage/voteFirst.sol";
contract ContractTest is Test {
EternalStorage private storageEth;
VoteFirst private voteFirst;
function setUp() public {
storageEth = new EternalStorage();
address storageAddress = address(storageEth);
voteFirst = new VoteFirst(storageAddress);
}
function testGetValueFromFirst() public {
voteFirst.vote();
uint256 voteNum = voteFirst.getNumberOfVotes();
assertEq(voteNum, 1);
}
}
该测试函数较为简单,核心在于使用address()
函数获得EternalStorage
合约部署地址,再使用此地址作为构造参数构造voteFirst
合约。使用forge test
进行测试。
在此处,我们也将代码部署到anvil
中进行测试。具体的环境搭建请参考上一篇教程。在此处,我们默认你已经完成了anvil
的启动,并且设置了LOCAL_ACCOUNT
系统变量为你的私钥,LOCAL_RPC_URL
为anvil
的API地址(默认为http://127.0.0.1:8545
)。由于此处部署的合约较多,所以我们将使用forge create
方法,具体可参考文档
首先部署src/EternalStorage/EternalStorage.sol
合约
forge create --rpc-url $LOCAL_RPC_URL --private-key $LOCAL_ACCOUNT src/EternalStorage/EternalStorage.sol:EternalStorage
将anvail
中输出的区块地址使用export
命令保存为STORAGE_ADDRESS
,在此处我使用的命令如下:
export STORAGE_ADDRESS=0x5fbdb2315678afecb367f032d93f642f64180aa3(该地址请自行更改)
接下来我们使用此地址作为构造器参数部署src/EternalStorage/voteFirst.sol
代码中的VoteContract
合约
forge create --rpc-url $LOCAL_RPC_URL --constructor-args $STORAGE_ADDRESS --private-key $LOCAL_ACCOUNT src/EternalStorage/voteFirst.sol:VoteFirst
将此合约地址保存为V1
系统变量。
export V1=0xe7f1725E7734CE288F8367e1Bb143E90bb3F0512
使用cast send
调用vote
函数进行测试:
cast send --private-key $LOCAL_ACCOUNT $V1 "vote()"
为了确定调用结果已经写入,我们使用cast call
调用getNumberOfVotes
函数:
cast call $V1 "getNumberOfVotes()(uint256)"
如果一切正常,此处应返回1
升级部署
由于在src/EternalStorage/voteFirst.sol
中使用了call
函数,在后续开发过程中,你发现该函数在官方文档中被警告You should avoid using .call() whenever possible when executing another contract function as it bypasses type checking, function existence check, and argument packing.
基于此警告,你希望改用其他方法编写智能合约,即升级当前的智能合约。我们在src/EternalStorage
文件夹下新建了voteSecond.sol
文件,并写如了以下代码:
// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.13;
contract EternalStorage {
mapping(bytes32 => uint256) public UIntStorage;
function getUIntValue(bytes32 record) public view returns (uint256) {
return UIntStorage[record];
}
function setUIntValue(bytes32 record, uint256 value) public {
UIntStorage[record] = value;
}
}
contract VoteSecond {
address eternalStorage;
constructor(address _eternalStorage) {
eternalStorage = _eternalStorage;
}
function getNumberOfVotes() public view returns(uint) {
return EternalStorage(eternalStorage).getUIntValue(keccak256('votes'));
}
function vote() public {
uint256 orgianVote = EternalStorage(eternalStorage).getUIntValue(keccak256('votes'));
EternalStorage(eternalStorage).setUIntValue(keccak256('votes'), orgianVote+1);
}
}
上述代码展示了在已知需调用合约代码的情况下的一种跨合约调用方式,核心为_Name(_Address)
。_Name
为需要调用合约的名称,在此处为EternalStorage
,也正是我们存储数据合约的名称。_Address
为合约地址。我们可以非常方便的在_Name(_Address)
后调用_Name
中的函数。这种方法更加倍推荐,也更加易于理解。
我们需要编写测试函数对合约是否升级生效进行测试,以下代码依旧写在test/EternalStorage/StoragteUpdate.t.sol
文件内:
// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.13;
import "forge-std/Test.sol";
// import "../../src/EternalStorage/EternalStorage.sol";
import "../../src/EternalStorage/voteFirst.sol";
import "../../src/EternalStorage/voteSecond.sol";
contract ContractTest is Test {
EternalStorage private storageEth;
VoteFirst private voteFirst;
VoteSecond private voteSecond;
function setUp() public {
storageEth = new EternalStorage();
address storageAddress = address(storageEth);
voteFirst = new VoteFirst(storageAddress);
voteSecond = new VoteSecond(storageAddress);
}
function testGetValueFromFirst() public {
voteFirst.vote();
uint256 voteNum = voteFirst.getNumberOfVotes();
assertEq(voteNum, 1);
}
function testGetValueFromSecond() public {
voteSecond.vote();
uint256 voteNum = voteSecond.getNumberOfVotes();
assertEq(voteNum, 1);
}
function testCrossGetValue() public {
voteFirst.vote();
uint256 voteNum = voteSecond.getNumberOfVotes();
assertEq(voteNum, 1);
}
}
注意此处删去了对EternalStorage.sol
合约的导入,原因在于voteSecond.sol
中已经含有此合约。
对于此合约的部署方式如下:
forge create --rpc-url $LOCAL_RPC_URL --constructor-args $STORAGE_ADDRESS --private-key $LOCAL_ACCOUNT src/EternalStorage/voteSecond.sol:VoteSecond
当然也与上次相同。我们将合约地址保存为系统变量:
export V2=0xCf7Ed3AccA5a467e9e704C703E8D87F634fB0Fc9
首先,我们测试一下该合约是否仍可访问上个合约内的数据:
cast call $V2 "getNumberOfVotes()(uint256)"
根据返回的结果,我们发现数据之前的投票数据仍可访问。读者可自行进行其他测试,会发现voteFirst
与voteSecond
由于共用EternalStorage
合约存储数据,双方的数据可以互相修改、互相访问。
当然,如果没有其他操作,我们第一次部署的voteFirst
将会一直运行(当然存在合约销毁的方式,在此处我们不再赘述)
优缺点
优点:
- 简单易懂。如果读者有OO的开发经验,对此方式并不陌生。
- 没有使用任何附加库,代码总体而言较为简洁
- 部署方便,只需要多运行一个用于数据存储的智能合约
缺点:
- 会出现多合约并行的情况,无法阻止旧合约访问和修改数据
- 合约地址改变,需要在接入端更改合约地址
- 由于无法增加存储变量,所以对旧合约进行完全的升级
在现实世界中,morpher使用了这种方法,他们的数据存储合约在这里,该合约对应的功能性合约是一个ERC20代币合约,代码在这里
EIP-897 Proxy
EIP-897是由openzeppelin-labs
提出一种可更新合约的编写方式。它使用delegatecall
函数通过代理运行的方式实现可升级合约。具体思路是首先编写代理合约,代理合约中含有fallback()
函数,将所有请求转发至此函数内运行。fallback
函数内通过solidity assembly
提供的底层方法将calldata
(即用户请求数据)转发到逻辑函数内运行,运行完毕后再将数据返回给用户。示意图如下: