Pre:
Upgradability patterns in Solidity — Part 1 学习记录大部分机翻+自己进行demo测试原作者示例代码github地址我的调试代码github地址
介绍:
软件开发是一个迭代过程,好的软件系统需要经常更新以消除现有错误并添加新功能。
智能合约是存储在区块链上的不可变程序。每个智能合约都有一个唯一的地址。用户将交易发送到智能合约的地址,以执行存储在该合约中的代码。
每次升级都可以上传新的智能合约,并在每次合约升级时为用户提供一个新地址。然而,这不是一个非常用户友好的设计模式。
为了方便用户更换地址,可以维护注册表合约。通过某合约提供的函数可方便让用户查询到新合约的最新地址。
例如,AAVE 维护一个地址提供者注册表
提供不同市场的活跃借贷池地址,即用户可以调用合约的函数获取最新的地址。
建议在不更改地址和丢失数据的情况下升级合约
接口模式:
这是一种常见的面向对象的编程模式。在这种模式下,用户总是与包含一些业务逻辑的主合约
进行交互;但是,主合约
也依赖于与卫星合约
通信的接口。主合约包含一个upgradeTo
方法,该方法允许主合约的所有者更改实现地址
实现:
ILogic.sol
接口定义了应用程序逻辑的函数
1 | // SPDX-License-Identifier: MIT |
SatelliteContractV1.sol
卫星合约单独驻留在区块链上,实现接口中描述的功能。
1 | // SPDX-License-Identifier: MIT |
MainContract.sol
MainContract
在内部使用 SatteliteContract
的数据来响应用户的查询。例如,当询问 firstName
时,MainContract
只返回存储在 SatteliteContract
中的值。
如果 MainContract
的所有者想要更改逻辑,他们需要部署一个单独的 SatteliteContract
并修改业务逻辑,并使用新的 SatteliteContract
的地址调用 MainContract
的upgradeTo
方法。
1 | // SPDX-License-Identifier: MIT |
缺点:
虽然实现简单,但接口模式非常死板且效率低下。
ILogic
接口用于 MainContract
内部,其地址不允许更改。因此,无法修改 ILogic
接口内的函数。此外,实际数据存储在扩展此 ILogic
接口的合同中。当用户设置名字和姓氏时,这些值会在 SatteliteContract
中更新。
MainContract
只是调用 SatteliteContract
中的函数。如果需要更新逻辑,则需要创建一个新的 SatteliteContract
。
一旦使用新的 SatteliteContract
的地址调用 MainContract
的 upgradeTo
方法,存储在原始 SatteliteContract
中的数据就不能再被 MainContract
访问。
所以,必须将原始 SatteliteContract
的存储复制到新的 SatteliteContract
以保持不同版本之间的应用程序状态完整。
不幸的是,复制数据是一项消耗大量gas的操作。SSTORE
操作需要 5000 到 20000 个 gas,复制大型存储数据可能会导致达到交易或块 gas 限制。
理想情况下,良好的可升级模式应该是灵活且存储高效的。
灵活性和存储效率:
可灵活修改逻辑是升级合约模式中比较好的特点。换句话说,开发人员应该能够轻松地修改现有逻辑或向合约添加/删除新逻辑。
在接口模式中,MainContrac
t 依赖于 ILogic
接口与 SatteliteContract
进行通信。
由于 MainContract
导入了 ILogic
接口,因此无法向 ILogic
接口添加新功能(不部署新的 MainContract
,因为这会更改应用程序地址)。
这种逻辑依赖关系使得合约耦合性比较大。一个好的方法是从 MainContract
中删除这个接口依赖,这样逻辑就很容易修改。
然而,移除接口会隐藏 MainContract
的逻辑实现。换句话说,删除逻辑接口后,MainContract
的职责将减少为仅充当用户和逻辑合约之间的代理。
幸运的是,Solidity 允许使用 fallback
函数和 delegatecall
关键字的代理模式。
在讨论代理模式实现之前,让我们先解决高效存储的问题。
存储管理在设计良好的升级模式中起着至关重要的作用。一个好的存储设计必须是节约gas的。
继承、永久和非结构化存储是一些常见存储模式的示例。许多存储模式依赖于代理和低级委托调用。因此,建议先了解代理和委托调用。
简单代理模式:
Logic
合约包含应用程序的业务逻辑。代理合约只是将传入调用委托给逻辑合约并将响应返回给用户。代理合约使用回退功能。当tx包含代理合约中不存在的方法时,将执行回退函数。
在回退函数中,我们使用 委托调用函数 来执行逻辑合约中的给定函数。
但是,存储属于代理合约。这是委托调用的一个重要属性。
如果合约 A 使用委托调用来执行合约 B 中存在的函数,则合约 A 本质上是允许合约 B 修改合约 A 的存储。换句话说,合约 A 的状态将被使用委托调用方法调用的函数修改,而合约 B 的存储保持不变。
通过编写测试用例来看看我们的代理模式是否表现正确。
1 | from brownie import Logic, Proxy, accounts, Contract |
我是用hardhat编写同样的测试逻辑
1 | // 测试结果 |
代理合约
使用 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
合约的第一个存储槽中的值。因此,十进制系统中的地址表示被返回并且测试用例失败。
同样,当调用 setMyInt()
函数时,修改了实现地址而不是 myInt
变量。
此问题称为存储冲突。这是一个关键问题,需要在我们的可升级模式中进行适当的存储管理。
让我们在本系列的下一部分中了解一些流行的存储管理模式。
Summary:
软件开发是一个迭代过程,好的软件系统需要经常更新以消除现有错误并添加新功能。
智能合约是存储在区块链上的不可变程序,每个智能合约都有一个唯一的地址。每次升级都可以上传新的智能合约,并在每次合约升级时为用户提供一个新地址。然而,这不是一个非常用户友好的设计模式。
为了方便用户更换地址,可以维护注册表合约。通过某合约提供的函数可方便让用户查询到新合约的最新地址。类似AAVE
的注册表合约
接着探讨了接口模式和代理模式的实现。
接口模式:
-
主合约
和子合约
继承和实现同一个接口合约
-
子合约
会去具体实现每个函数 -
主合约
不去具体实现,仅返回子合约
的调用结果,类似bridge的作用 -
用户和
主合约
交互,主合约地址不变 -
升级的时候,仅升级
子合约
-
但因为接口合约有写死了,所以只能改变原有函数的逻辑,不能增减函数
-
每次升级,新的子合约和旧的子合约间要进行数据同步,耗gas
简单代理模式:
合约间的关系:
-
逻辑合约
包含应用程序的业务逻辑。 -
代理合约
只是将传入调用委托给逻辑合约
并将响应返回给用户。 -
代理合约
使用回退功能。当tx包含代理合约中不存在的方法时,将执行回退函数。 -
在回退函数中,我们使用 委托调用函数 来执行实现(逻辑)合约中的给定函数。
存储:
-
委托调用的一个重要属性:存储在代理合约里。
-
代理合约
委托调用来执行逻辑合约
中存在的函数,代理合约
允许逻辑合约
修改其存储,而逻辑合约
的存储保持不变。
简单代理模式的实现中出现了存储冲突的问题,需要在接下来的系列文章中继续探讨