使用非代理模式实现合约升级
solidity智能合约部署到链上之后,代码是不能再修改的,这样有好也有坏。
- 好:用户可以知道代码的运行逻辑,不用担心代码被人私自篡改从而执行恶意操作;
- 坏:一旦发现之前部署的智能合约出现bug,hacker可以利用bug执行恶意操作,而本着合约不可篡改的特性,合约不能进行修复和升级,只能通过重新部署新的合约,而这样一来用户的数据将会被清空,若是要实现数据迁移则付出的gas成本会很高。
正是基于如上痛点,提出了可升级的智能合约的理念,该理念的目的是:实现智能合约在部署之后,还可以进行合约升级。
当下有两种主流的合约升级方式:
- 数据逻辑分离
- 代理模式
今天要学习的是采用
数据逻辑分离模式
实现合约升级。
将数据和逻辑保存在不同的合约中,逻辑合约负责调用和操作数据合约。这种方式也被称为 永久数据存储
模式。
如何理解这种模式?
本质上就是:将逻辑合约和存储数据合约分离成俩个合约,逻辑合约负责调用存储数据合约。
执行逻辑:逻辑合约V1
通过call
的方式去调用stroage contract
,当合约需要升级的时候,将V1
替换成V2
然后用户通过V2
版本的逻辑合约去调用storage contract
。
对于合约的升级一定是有严格的访问控制的,升级操作需要添加严格的控制权,若是anyone都可以执行升级操作,那么合约很容易报废,待会我会复现。
本次复现的逻辑是:storage contract
合约所有者将合约所有者权移交给V1
,V1
负责操作storage contract
,同时它还具有移交storage contract
所有权的功能。
代码写得很粗糙,旨在复现逻辑过程 :)。
本次复现使用了三个合约:
StorageTickts
合约所有权的功能(具有访问控制)。StorageTickts
合约所有权的功能(具有访问控制)。// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import "https://github.com/OpenZeppelin/openzeppelin-contracts/blob/master/contracts/access/Ownable.sol";
contract StorageTickets is Ownable {
mapping(bytes32 => uint) VoteAmounts; // solt 1
mapping(address => bool) BooleanVote; // slot 2
constructor() Ownable(msg.sender) {}
function getVoteAmounts(bytes32 record) public view returns (uint) {
return VoteAmounts[record];
}
function setVoteAmounts(bytes32 record, uint value) public {
VoteAmounts[record] = value;
}
function getBooleanVote(address account) public view returns (bool){
return BooleanVote[account];
}
function setBooleanVote(address account, bool value) public {
BooleanVote[account] = value;
}
}
// V1 版本
contract V1 {
address owner;
StorageTickets storageTickets;
constructor(address _storageTickets) {
owner = msg.sender;
storageTickets = StorageTickets(_storageTickets);
}
modifier onlyOwner() {
require(msg.sender == owner);
_;
}
// 查询 keccak256(abi.encodePacked("votes"))获取的票数
function getNumberOfVotes() public view returns (uint256) {
bytes32 votes = keccak256(abi.encodePacked("votes"));
return storageTickets.getVoteAmounts(votes);
}
// 投票
function vote() public {
bytes32 votes = keccak256(abi.encodePacked("votes"));
storageTickets.setVoteAmounts(votes, storageTickets.getVoteAmounts(votes) + 1);
}
function transferOwner(address _newOwner) public onlyOwner {
storageTickets.transferOwnership(_newOwner);
}
}
StorageTickets.sol
V1
构造器的参数,部署V1
StorageTickets
的所有者将所有权通过transferOwnerShip()
函数,移交所有权给V1
用户0x4B20993Bc481177ec7E8f571ceCaE8A9e22C02db
连续5次点击V1的vote函数,可以看到票数为5。
假如此时项目方发现这个明显的bug,没有限制每个用户的投票次数,这可能会导致有人恶意刷单,项目方明确规定每人只能投一次票,那么很明显合约需要升级,升级成V2版本。
// V2 版本
contract V2 {
address owner;
StorageTickets storageTickets;
constructor(address _storageTickets) {
owner = msg.sender;
storageTickets = StorageTickets(_storageTickets);
}
modifier onlyOwner() {
require(msg.sender == owner);
_;
}
// 查询 keccak256(abi.encodePacked("votes"))获取的票数
function getNumberOfVotes() public view returns (uint256) {
bytes32 votes = keccak256(abi.encodePacked("votes"));
return storageTickets.getVoteAmounts(votes);
}
// 投票
function vote() public {
require(storageTickets.getBooleanVote(msg.sender) == false, "Fail, you have already voted:)");
storageTickets.setBooleanVote(msg.sender, true);
bytes32 votes = keccak256(abi.encodePacked("votes"));
storageTickets.setVoteAmounts(votes, storageTickets.getVoteAmounts(votes) + 1);
}
function transferOwner(address _newOwner) public onlyOwner {
storageTickets.transferOwnership(_newOwner);
}
}
storageTickets
合约地址部署V2此时keacck256("vote")
的票数还是5票
用户0x4B20993Bc481177ec7E8f571ceCaE8A9e22C02db
还想像之前那样不断的投票,当他第二次进行投票时,他的操作被revert()
了。
优点:
缺点:
译文:变形合约。
忽略数据不变性,这也算是一种可升级合约的方式,这是采用create2
的操作码实现的。众所周知,在以太坊中部署合约可以获取到一个地址,在代码中操作的也是合约地址,那么理论上,一个合约地址可以看成是一个合约,合约地址不变,那么就可以间接看作是合约没有改变。而通过create2
操作码进行升级的理念便是,在同一个合约地址上部署上不同的逻辑,合约的执行逻辑主要是依靠runtimeCode
,这要每次部署合约时传入的runtimeCode
不一样就能实现。
那么如何重复部署同一个合约地址呢?
这需要用到selfdestruct
和create2
盐。selfdestruct
负责销毁合约,只有销毁了合约才能再部署同一合约地址,此外,自毁功能是必须要提供的,否则该升级模式将会失败。而且自毁功能需要有访问控制。
// create2部署合约的原理
0xFF + address(deployer) + salt+ keccak256(creationCode)
通过create2部署合约到同一个地址,0xFF
、address(deployer)
和salt
容易保证不变,但是要替换原合约的逻辑,则必须要更改合约的内容,这样一来creationCode
不就会发生改变了吗,有什么办法能保证creationCode
不变,而runtimeCode
改变?
答案:有的。可以通过solidity的汇编语言,返回函数的runtimecode
,而这个runtimeCode
从构造器中传入,直接看代码。
contract Target {
constructor() {
Factory factory = Factory(msg.sender);
bytes memory runtimeCode = factory.runtimeCode();
assembly {
return(add(runtimeCode, 0x20), mload(runtimeCode))
}
}
}
这是目标合约,通过Target
的creationCode
不变,从而达成上述要求。
contract Factory {
address public owner;
bytes public runtimeCode;
modifier onlyOwner() {
require(msg.sender == owner);
_;
}
function deploy(bytes memory _runtimeCode) external onlyOwner returns(address target) {
runtimeCode = _runtimeCode;
bytes memory creationCode = type(Target).creationCode;
assembly {
target := create2(
0, // msg.value
add(creationCode, 0x20), // the start of data
mload(creationCode), // creationCode.length
0x00 // salt
)
}
}
}
这是工厂合约负责部署目标合约,部署功能需要有访问控制,预防恶意操作。
复现:
测试合约:
contract Test {
address target;
constructor(address _target) {
target = _target;
}
function test() public returns(uint256) {
(bool ok, bytes memory rtd) = target.call(abi.encodeWithSignature("cal(uint256,uint256)", 1, 2));
require(ok, "call fail:)");
return abi.decode(rtd, (uint256));
}
function kill() public {
(bool ok, bytes memory rtd) = target.call(abi.encodeWithSignature("kill()"));
require(ok, "kill fail(:");
}
}
辅助合约,获取V1和V2的runtimeCode
:
contract GetRuntimeCode {
function getV1() public pure returns (bytes memory){
return type(Logic_V1).runtimeCode;
}
function getV2() public pure returns (bytes memory){
return type(Logic_V2).runtimeCode;
}
}
逻辑合约V1,计算方式为加法运算:
contract Logic_V1 {
function cal(uint256 n1, uint256 n2) external pure returns (uint256) {
return n1 + n2;
}
function kill() public {
selfdestruct(payable(msg.sender));
}
}
逻辑合约V2,计算方式为乘法运算:
contract Logic_V2 {
function cal(uint256 n1, uint256 n2) external pure returns (uint256) {
return n1 * n2;
}
function kill() public {
selfdestruct(payable(tx.origin));
}
}
执行逻辑:
部署GetRuntimeCode
合约
部署Factory
合约
通过GetRuntimeCode
合约的getV1()
函数获取,V1的runtimeCode
: run_v1
。
将run_v1
作为参数,调用deploy()
函数,部署Target
合约,
地址为:0x0c1720ee8283EB0D46170ba774098Ae648C701c1
。
将该地址作为参数,部署Test
合约,并调用 test()
函数,结果如下:
升级逻辑:
Test
合约的kill
函数,将target
合约销毁。通过GetRuntimeCode
合约的getV2()
函数获取,V2的runtimeCode
: run_v2
。
将run_v2
作为参数,调用deploy()
函数,部署Target
合约,
随后继续调用 test()
函数,结果如下:
此时,你会发现结果还是原来的3
,这是为什么呢???
这是因为在EIP-4756
中提及过,将移除selfdestruct
这一操作码,这也是该种合约升级模式的弊端之一。
看到我使用的编译版本:
在Cancun
硬分叉之后,这个自毁功能是被移除了的,那么该如何复现呢?
答案:换一个网络,以及换低版本编译器。我换成如下:
再次重复如上步骤,输出结果为:2
。
复现完毕。
谈谈这个模式的好与坏
delegatecall
代理,效率高不需要转发调度。initialize()
代替constructor()
。selfdestruct
在接下来的网络升级中会可能会被移除。selfdestruct
会将合约数据抹除。selfdestruct
和部署一次合约。如果觉得我的文章对您有用,请随意打赏。你的支持将鼓励我继续创作!