0%

StudyRecord-Upgradability_patterns_in_Solidity_Part1

Pre:

Upgradability patterns in Solidity — Part 1 学习记录大部分机翻+自己进行demo测试原作者示例代码github地址我的调试代码github地址

介绍:

软件开发是一个迭代过程,好的软件系统需要经常更新以消除现有错误并添加新功能。

智能合约是存储在区块链上的不可变程序。每个智能合约都有一个唯一的地址。用户将交易发送到智能合约的地址,以执行存储在该合约中的代码。

每次升级都可以上传新的智能合约,并在每次合约升级时为用户提供一个新地址。然而,这不是一个非常用户友好的设计模式。

为了方便用户更换地址,可以维护注册表合约。通过某合约提供的函数可方便让用户查询到新合约的最新地址。

例如,AAVE 维护一个地址提供者注册表提供不同市场的活跃借贷池地址,即用户可以调用合约的函数获取最新的地址。

20220628000242

建议在不更改地址和丢失数据的情况下升级合约

接口模式:

这是一种常见的面向对象的编程模式。在这种模式下,用户总是与包含一些业务逻辑的主合约进行交互;但是,主合约也依赖于与卫星合约通信的接口。主合约包含一个upgradeTo方法,该方法允许主合约的所有者更改实现地址

实现:

ILogic.sol

接口定义了应用程序逻辑的函数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// SPDX-License-Identifier: MIT
// An interface is like the contract ABI in Solidity.
// The motivation for interfaces
// An interface can only contain external methods.
// It can only inherit from other interfaces.
// They do not have their own storage. Therefore, state variables are not allowed
// They can have user-defined types such as Struct and Enums.'
// They can have event declaration.
// A base contract can only implement some of the interface methods.
// Interfaces allows one contract to communicate with othercontracts that implement the interface.

pragma solidity ^0.8.0;

interface ILogic {
struct User{
bytes32 firstName;
bytes32 lastName;
}
function getFirstName() external returns(bytes32);
function getLastName() external returns(bytes32);
function setFirstName(bytes32 _firstName) external;
function setLastName(bytes32 _lastName) external;
}

SatelliteContractV1.sol

卫星合约单独驻留在区块链上,实现接口中描述的功能。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
import "./../../interfaces/simple-interface/ILogic.sol";

contract SatelliteContractV1 is ILogic {

ILogic.User logic;
function getFirstName() override(ILogic) external view returns(bytes32){
return logic.firstName;
}

function getLastName() override(ILogic) external view returns(bytes32){
return logic.lastName;
}

function setFirstName(bytes32 _firstName) override(ILogic) external{
logic.firstName = _firstName;
}

function setLastName(bytes32 _lastName) override(ILogic) external{
logic.lastName = _lastName;
}
}

MainContract.sol

MainContract 在内部使用 SatteliteContract 的数据来响应用户的查询。例如,当询问 firstName 时,MainContract 只返回存储在 SatteliteContract 中的值。

如果 MainContract 的所有者想要更改逻辑,他们需要部署一个单独的 SatteliteContract 并修改业务逻辑,并使用新的 SatteliteContract 的地址调用 MainContractupgradeTo方法。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

import "./../../interfaces/simple-interface/ILogic.sol";


contract MainContract {
address owner;

ILogic satteliteContract;

constructor() {
owner = msg.sender;
}

// 1.修改业务逻辑 2.执行更新函数
function upgradeTo(address newImplementation) public {
// 更新
require(msg.sender == owner, "Only Owner");
satteliteContract = ILogic(newImplementation);
}

// MainContract 在内部使用 SatteliteContract 的数据来响应用户的查询
function getFirstName() external returns (bytes32){
return satteliteContract.getFirstName();
}

function getLastName() external returns (bytes32){
return satteliteContract.getLastName();
}

function setFirstName(bytes32 _firstName) external {
satteliteContract.setFirstName(_firstName);
}

function setLastName(bytes32 _lastName) external {
satteliteContract.setLastName(_lastName);
}
}

缺点:

虽然实现简单,但接口模式非常死板且效率低下
ILogic 接口用于 MainContract 内部,其地址不允许更改。因此,无法修改 ILogic 接口内的函数。此外,实际数据存储在扩展此 ILogic 接口的合同中。当用户设置名字和姓氏时,这些值会在 SatteliteContract 中更新。
MainContract 只是调用 SatteliteContract 中的函数。如果需要更新逻辑,则需要创建一个新的 SatteliteContract

一旦使用新的 SatteliteContract 的地址调用 MainContractupgradeTo 方法,存储在原始 SatteliteContract 中的数据就不能再被 MainContract 访问。

所以,必须将原始 SatteliteContract 的存储复制到新的 SatteliteContract 以保持不同版本之间的应用程序状态完整。

不幸的是,复制数据是一项消耗大量gas的操作。SSTORE 操作需要 5000 到 20000 个 gas,复制大型存储数据可能会导致达到交易或块 gas 限制。

理想情况下,良好的可升级模式应该是灵活且存储高效的。

灵活性和存储效率:

可灵活修改逻辑是升级合约模式中比较好的特点。换句话说,开发人员应该能够轻松地修改现有逻辑或向合约添加/删除新逻辑。

在接口模式中,MainContract 依赖于 ILogic 接口与 SatteliteContract 进行通信。

由于 MainContract 导入了 ILogic 接口,因此无法向 ILogic 接口添加新功能(不部署新的 MainContract,因为这会更改应用程序地址)。

这种逻辑依赖关系使得合约耦合性比较大。一个好的方法是从 MainContract 中删除这个接口依赖,这样逻辑就很容易修改。

然而,移除接口会隐藏 MainContract 的逻辑实现。换句话说,删除逻辑接口后,MainContract 的职责将减少为仅充当用户和逻辑合约之间的代理。

幸运的是,Solidity 允许使用 fallback 函数和 delegatecall 关键字的代理模式。
在讨论代理模式实现之前,让我们先解决高效存储的问题。
存储管理在设计良好的升级模式中起着至关重要的作用。一个好的存储设计必须是节约gas的。

继承、永久和非结构化存储是一些常见存储模式的示例。许多存储模式依赖于代理和低级委托调用。因此,建议先了解代理和委托调用。

简单代理模式:

Logic 合约包含应用程序的业务逻辑。代理合约只是将传入调用委托给逻辑合约并将响应返回给用户。代理合约使用回退功能。当tx包含代理合约中不存在的方法时,将执行回退函数。

在回退函数中,我们使用 委托调用函数 来执行逻辑合约中的给定函数。

但是,存储属于代理合约。这是委托调用的一个重要属性。

如果合约 A 使用委托调用来执行合约 B 中存在的函数,则合约 A 本质上是允许合约 B 修改合约 A 的存储。换句话说,合约 A 的状态将被使用委托调用方法调用的函数修改,而合约 B 的存储保持不变。

通过编写测试用例来看看我们的代理模式是否表现正确。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
from brownie import Logic, Proxy, accounts, Contract
import pytest

def deploy_contracts():
logic_tx = Logic.deploy({"from": accounts[0]})
proxy_tx = Proxy.deploy({"from": accounts[0]})
proxy_tx.setImplementationAddress(logic_tx.address)
return (proxy_tx, logic_tx.address)

def test_proxy_pattern_implementation_equals_to_logic_address():
(proxy, logicAddress) = deploy_contracts()
assert proxy.getImplementationAddress() == logicAddress

def test_can_get_value_from_implementation():
(proxy, logicAddress) = deploy_contracts()
# 重点
proxy_logic = Contract.from_abi("Logic", proxy.address, Logic.abi)
assert proxy_logic.getMyInt() == 10

def test_can_set_value_from_proxy():
(proxy, logicAddress) = deploy_contracts()
# 重点:通过代理合约去调用 logic合约
proxy_logic = Contract.from_abi("Logic", proxy.address, Logic.abi)
proxy_logic.setMyInt(20, {"from": accounts[0]})
assert proxy.getImplementationAddress() == logicAddress

我是用hardhat编写同样的测试逻辑

1
2
3
4
5
6
7
8
// 测试结果
expect logicMyInt = 10
// 代理合约调用 logic合约查询出来的MyInt,与预期的10不一致
logicMyInt: 546584486846459126461364135121053344201067465379
// 代理合约查询出来的实现地址
implementationAddress: 0x0000000000000000000000000000000000000014
// 部署的logic合约地址
logic Address: 0x5FbDB2315678afecb367f032d93F642f64180aa3

代理合约使用 delegatecall 方法来调用逻辑合约getMyInt() 函数。getMyInt() 函数返回 myInt 变量中的值。创建逻辑合约时,其构造函数将整数值 10 分配给 myInt 变量。但是,测试表明 getMyInt 返回一个非常大的整数值,不等于 10。这是由于存储冲突引起的。

在智能合约中,存储分为 32 字节的插槽。EVM 是一个字长为 256 位(32 字节)的状态机。
合约的状态变量(myInt)映射到这些槽。
访问状态变量意味着访问存储在 EVM 插槽中该状态变量位置的值。

例如,uint 256 myInt逻辑合约 中的第一个状态变量,而 uint256 类型值需要 32 个字节(一个完整的插槽)。由于myInt是第一个状态变量,所以它的值存储在第一个 EVM 插槽中。在 getMyInt() 函数中返回 myInt 意味着返回存储在 EVM 槽中变量 myInt 位置的值。请记住,代理合约 正在使用delegatecall来调用 逻辑合约中的 getMyInt() 函数。

由于通过委托调用该函数,EVM 将使用代理合约的存储而不是逻辑合约的存储。
但是,代理合约存储有一个地址变量存储在 EVM 的第一个插槽中。

当从存储访问 myInt 变量时,EVM 返回存储在 Proxy 合约的第一个存储槽中的值。因此,十进制系统中的地址表示被返回并且测试用例失败。

20220630194901

同样,当调用 setMyInt() 函数时,修改了实现地址而不是 myInt 变量。
此问题称为存储冲突。这是一个关键问题,需要在我们的可升级模式中进行适当的存储管理。

让我们在本系列的下一部分中了解一些流行的存储管理模式。

Summary:

软件开发是一个迭代过程,好的软件系统需要经常更新以消除现有错误并添加新功能。

智能合约是存储在区块链上的不可变程序,每个智能合约都有一个唯一的地址。每次升级都可以上传新的智能合约,并在每次合约升级时为用户提供一个新地址。然而,这不是一个非常用户友好的设计模式。

为了方便用户更换地址,可以维护注册表合约。通过某合约提供的函数可方便让用户查询到新合约的最新地址。类似AAVE的注册表合约

接着探讨了接口模式和代理模式的实现。

接口模式:

  • 主合约子合约继承和实现同一个接口合约

  • 子合约会去具体实现每个函数

  • 主合约不去具体实现,仅返回子合约的调用结果,类似bridge的作用

  • 用户和主合约交互,主合约地址不变

  • 升级的时候,仅升级子合约

  • 但因为接口合约有写死了,所以只能改变原有函数的逻辑,不能增减函数

  • 每次升级,新的子合约和旧的子合约间要进行数据同步,耗gas

20220628112359

简单代理模式:

合约间的关系:

  • 逻辑合约包含应用程序的业务逻辑。

  • 代理合约只是将传入调用委托给逻辑合约并将响应返回给用户。

  • 代理合约使用回退功能。当tx包含代理合约中不存在的方法时,将执行回退函数。

  • 在回退函数中,我们使用 委托调用函数 来执行实现(逻辑)合约中的给定函数。

存储:

  • 委托调用的一个重要属性:存储在代理合约里。

  • 代理合约委托调用来执行逻辑合约中存在的函数,代理合约允许逻辑合约修改其存储,而逻辑合约的存储保持不变。

20220628112426

简单代理模式的实现中出现了存储冲突的问题,需要在接下来的系列文章中继续探讨

Refs: