🐸14 可升级合约
[TOC]
可升级合约
公链账本内交易记录不可篡改,交易包括 Token 转移、合约部署以及合约交易。智能合约一旦部署后,无法更改合约源码。中心化程序的开发人员可以对程序进行更新,修复 bug 或引入新功能,而在以太坊上是不可能对合约程序本身进行更新。虽然不能对合约本身进行更新,但是可以通过一些设计方案把升级设计成可以升级的合约。
不建议设计可升级的智能合约,目前基于数据分离模式和代理模式设计的可升级合约都大大增加复杂性,出现缺陷的可能性很高,也会降低智能合约的信任。优先选择努力追求简单、不可变和安全的合约,而不是导入大量的代码来推迟功能和安全问题。
合约升级的方式
目前通用的可升级智能合约设计方式有代理、分离逻辑和数据、通过键值对分离数据和逻辑、部分升级等 4 种,可以归类为代理和数据分离两种模式。
代理合约
代理合约的主要思路是通过 delegatecall 指令调用目标合约的函数,而目标合约是可以升级的。因为 delegatecall 保存了函数调用的状态,目标合约的逻辑中可以修改 Proxy 合约的状态并且会调用完成后保留在 Proxy 合约中。使用 delegatecall 指令时 msg.sender 的值是 Proxy 合约的调用者。
分离逻辑和数据
分离逻辑和数据是将合约中的变量、结构体、映射等数据和相关的 getter、setter 函数放在数据合约中,将所有商业逻辑的实现代码放在一个逻辑合约中。逻辑发生变化但数据还在同一个合约,逻辑合约就可以更新逻辑重新部署继续调用数据合约就可以实现升级。合约可以通过引导用户使用新的逻辑合约,并且修改数据权限来执行 getter 和 setter 函数。
通过键值对分离数据和逻辑
数据分离模式的一种。区别在于访问数据时所有数据都经过了抽象化从而可以通过键值对来访问。这种方式也被称为永恒存储模式,将值存储为 bytes32,然后用类型转换来获取原来的值,这增加了数据模型的复杂性,不熟悉复杂数据结构容易出错。
contract DataContract is Owner {
mapping(bytes32 => uint) uIntStorage;
function getUint(bytes32 key) view public returns(uint) {
return uintStorage[key];
}
function setUint(bytes32 key, uint new_val) onlyOwner public {
uintStorage[key] = new_val;
}
}部分升级
创建全科可升级的合约存在合约不可篡改性的信任问题。如果合约可以全部升级,那合约在发布部署后可以改动,而这严重违反了智能合约不可篡改的约定。所以在多数情况下,通行的做法是设计部分可以升级的合约。合约的核心功能可以是不可升级的,而其它部分可以使用升级策略。
Ethereum Name Service ENS 合约的核心是不能修改的,但域名注册可以被管理员升级。“.eth” 域名的注册管理合约是一个工厂合约,当切换到一个新的域名管理器时可以通过重新连接老的合约来实现。
DEX 去中心化交易所的合约可以被全部升级而代理合约不变。代理合约包含用户的资金和设置,因为这个合约需要绝对的信任,所以合约是不可以升级的。
合约升级的模式
可升级合约的设计的几种方式总结下来就是基于数据分离和基于 delegatecall 的代理两种模式。数据分离模式的优点是简单,它不需要像 delegatecall 模式那样的底层的专业知识,不过,最近 delegatecall 模式受到了广泛的关注,开发人员倾向于选择这种解决方案,因为文档和例子更容易找到。事实是两种模式都有相当大的风险。
数据分离模式
数据分离模式将逻辑和数据保持在不同的合约中。逻辑合约可以在需要时进行升级,数据合约不可以升级,只有所有者可以改变其内容。这种模式要着重考虑如何存储数据以及如何进行升级。数据分离模式的风险是它增加了代码的复杂性,并且需要更复杂的授权模式。
数据存储策略
如果在整个升级过程中需要的变量保持不变,可以使用一个简单的设计,让数据合约持有这些变量,以及它们的 getters 和 setters。只有合约所有者能够调用 setters。必须清楚地确定所需的状态变量,这种方法适用于基于 ERC20 代币的合约,因为它们只需要存储余额。
如果未来需要存储新的数据,可以创建新的数据合约存储。可以在不同的合约中分割数据,代价是额外的逻辑合约调用和授权。如果不打算经常升级合约,额外的成本可能是可以接受的。如果将状态变量添加到逻辑合约中,这些变量在升级过程中不会被保留,但对于实现逻辑来说可以使用,如果想保留它们,也可以把它们迁移到新的逻辑合约中。
升级方案
数据分离模式提供了几种不同的策略,取决于数据的存储方式。最简单的方法之一是将数据合约的所有权转移到一个新的逻辑合约,然后禁用原来的逻辑合约。要禁用之前的逻辑合约,需要将旧的逻辑合约暂停或者将旧的逻辑合约指向零地址。另一个解决方案是将旧逻辑合约的调用转移到新逻辑合约,这个解决方案增加了复杂性,必须维护更多的合约。还有是部署一个代理合约来调用新的逻辑合约,代理合约为用户提供了一个固定的入口,并对责任进行了区分,比调用转移方案更清晰,但是它会有额外的 Gas 成本。
代理模式
像数据分离模式一样,代理模式将合约分为逻辑合约(也称实现合约)、代理合约,代理合约持有数据。逻辑合约是逻辑层,代理合约是存储层。在这种模式中,代理合约用 delegatecall 调用逻辑合约,与数据分离模式正好相反。delegatecall 的特点是允许一个合约执行另一个合约的代码,同时保持调用者的上下文不会变,使用 delegatecall 调用别的合约可以更新发起调用的合约的状态。
用户与代理合约交互,合约逻辑可以升级。要升级时,将新部署合约地址更新到代理合约中实现升级,旧的逻辑合约就被丢弃了。目前主流的可升级合约设计代理模式是基于 Transparent 或 UUPS Proxies,两者共享相同的升级接口,但 UUPS 升级由实现逻辑合约处理,并且最终可以被删除,另一方面,Transparent 代理在代理本身中包含升级和管理逻辑,升级逻辑驻留在代理合约中就意味着升级由代理合约处理。OpenZeppelin 建议使用 UUPS 代理,Transparent 部署更昂贵,Transparent 把升级逻辑放在代理合约中也会造成函数选择器冲突,UUPS 的升级逻辑和一般逻辑放在一起更为轻量。
代理模式注意事项:考虑继承的顺序,因为会影响内存布局。考虑变量的声明顺序,例如,变量的覆盖,甚至类型的改变都会影响程序员与delegatecall交互时的意图。注意编译器可能会使用填充和/或将变量打包在一起,例如,如果将两个连续的 uint256 改为两个 uint8,编译器可以将这两个变量存储在一个槽中,而不是两个。确认变量的内存布局是否得到重视,如果使用不同版本的 solc 或者启用不同的优化功能,不同版本的 solc 计算存储偏移的方式不同。变量的存储顺序可能会影响 Gas 成本、内存布局,从而影响 delegatecall 的结果。检查代理中的函数名称,避免函数名称碰撞,与预定函数具有相同 Keccak 哈希值的代理函数会优先被调用。
代理模式的可升级合约没有构造方法,它使用 initializer 方法,逻辑合约通过 initializer 方法初始化,initializer 方法是一个自定义方法可以反复调用,而 constructor 方法在程序级别只会被调用一次,因此要确保 initializer 方法不会被多次调用。常见的防止 initializer 被多次调用的方案是使用一个全局变量 initialized 作为 flag,第一次运行会把 initialized 设置为已经运行过的状态,每次调用都通过 initialized 进行判断是否已经运行过 initializer 方法。
数据存储策略
在使用代理模式有继承存储、永恒存储、非结构化存储等三种方式可以将数据和逻辑分开。继承存储方式使用 Solidity 继承来确保调用者和被调用者有相同的内存布局。永恒存储方式是逻辑分离的键值存储版本。非结构化存储方式是唯一不会因为内存布局不正确而造成潜在内存损坏的策略,它依赖于内联汇编代码和存储变量的自定义内存管理。
三种存储模式底层都依赖 delegatecalls 实现。虽然 Solidity 提供了delegatecall 方法,但它仅在调用成功后返回 true / false,无法管理返回的数据。需要注意,当调用的方法在合约中不存在时,合约会调用 fallback 函数,可以编写 fallback 函数的逻辑处理这种情况,代理合约使用自定义的 fallback 函数将调用请求重定向到其他合约中。另外,每当合约 A 将调用代理到另一个合约 B 时,它都会在合约 A 的上下文中执行合约 B 的代码,它会保留 msg.value 和 msg.sender 值,并且每次存储修改都会影响合约 A。
继承存储
继承存储方式需要逻辑合约包含代理合约所需的存储结构,代理和逻辑合约都继承相同的存储结构,以确保两者都存储必要的代理状态变量。对于这种方式,可以使用 Registry 合约来跟踪逻辑合约的不同版本,为了升级到新的逻辑合约,开发者需要在注册合约中将新升级的合约进行注册,并要求代理升级到新合约,拥有注册合约并不会影响存储机制。
永久存储
在永久存储模式中,存储结构是在单独的合约中定义,代理合约和逻辑合约都继承存储合约。存储合约包含逻辑合约所需的所有状态变量,同时,代理合约也能够识别这些状态变量,因此代理合约在定义升级所需要的状态变量时,不必担心所定义的状态变量会被覆盖。逻辑合约的后续版本均不应定义任何其它状态变量。逻辑合约的所有版本都必须始终使用最开始定义存储结构。
非结构化存储
非结构化存储模式类似继承存储模式,但并不需要目标合约继承与升级相关的任何状态变量。此模式使用代理合约中定义的非结构化存储插槽来保存升级所需的数据。
代理存储冲突和非结构化存储
代理模式中代理合约 proxy 至少需要一个状态变量来保存执行合约地址。默认情况下,Solidity 在智能合约存储中按顺序存储变量:第一个声明的变量到零号槽,下一个到一号槽,以此类推,只有映射和动态大小的数组例外。按照这个规则,在接下来的代理合约中,其实现地址将被保存到存储槽0。然而,若逻辑合约 impl 也有一个全局变量同样位于存储槽0,则调用 impl.slot_0 其实质是修改了 proxy 合约的 slot_0 的值,从而发生了 impl 合约和 proxy 合约的储存冲突。
解决存储冲突的简单方案是在 Impl 合约中,添加与代理合约一样的全局变量,并保证添加的位置和顺序。但是这样做的缺点是添加了多余的不需要的全局变量,并且降低了重复利用率。为了解决这个问题,Openzepplin 提出了非结构化存储的解决方案,即设定一个固定的 slot 用于存储 impl 地址,与合约中的其它全局变量顺序无关,使得 Etherscan 这样的区块浏览器可以很容易地识别这些代理,因为任何在这个非常具体的插槽中具有类似地址值的合同都很可能是一个代理,并解析出逻辑合约地址。
Append-only 的存储合约
不同版本的实现合约中,需要保证全局变量的存储插槽不被破坏。在两个不同版本的实现合约之间,重新排序变量 、插入新的变量 、改变变量的类型 、甚至改变合约的继承链 等操作都有可能破坏存储插槽,唯一安全的改变是将状态变量追加到任何现有的变量之后,这是智能合约升级的一个主要限制。
解决该问题的开发实践是使用 append-only 的储存合约,它和继承存储具有相似处。在该模式下,状态存储在单独的存储合约中,并且只能追加,不允许删除。实现合约则继承该存储合约,以便于使用相应的状态。然后,每次需要增加一个新的状态变量时,都可以扩展存储合约。Solidity 保证变量在存储中的排列取决于继承链的顺序,所以从合约中扩展来添加一个新的变量可以确保它被附加在现有变量之后。但这种方法有一个很大的缺点,继承链中的所有合约都必须遵循这种模式,以防止混淆,包括来自外部库的合约,它们定义了自己的状态。
EIP-1967
Transparent 和 UUPS 都是实现了EIP-1967,EIP-1967 规定一个通用的存储插槽,用于在代理合约中的特定位置存放逻辑合约的地址。
EIP-1967 在设计三个特定的插槽时会将计算得到的地址减去 1,目的是为了不能知道哈希的前像,进一步减少可能的攻击。EIP-1967 设计特定的插槽,而不是给定一个返回逻辑合约地址的函数,目的在于防止函数签名攻击。函数签名攻击的思路:由于 Solidity 中识别一个函数,靠的是函数签名,而函数签名是函数哈希后的前4个bytes,非常容易被碰撞。在一个独立的 Solidity 文件中,编译器自己会去检查所有的 external 和 public 函数是否存在函数签名碰撞,而对于代理模式的合约文件,可能存在 proxy 合约中的函数签名与 impl 合约中的函数签名碰撞。一旦发生这种碰撞,proxy 合约中的函数就会被直接调用,而不是 impl 合约对应的函数。
EIP-1822
UUPS 合约升级是 EIP-1822 的具体实现。EIP-1822 的合约升级模式与 Transparent 合约升级模式的不同点在于 EIP-1822 的代理合约只读取实现合约的地址,并将所有的方法都代理给实现合约,包括修改实现合约地址的逻辑部分也在实现合约中。而 Transparent 合约升级模式中,proxy 合约管理着实现合约的地址,要实现合约升级,只需要在 proxy 合约中更改实现合约的地址即可。EIP-1822 的实现合约既包含了普通的业务逻辑处理,更包含了自身的升级逻辑处理。EIP-1822 的实现合约部分,都需要继承自一个公共的可升级实现合约:proxiable.sol。
所有的实现合约都继承自 proxiable 合约,并实现自己的业务逻辑。因为代理合约只是从插槽 keccak256("PROXIABLE") 处读取实现合约的地址,而实现合约可以通过 proxiable 中的 updateCodeAddress 方法来更新这个地址,从而实现代理合约中对应插槽 keccak256("PROXIABLE") 位置处的地址改变为目标地址。
Openzeppelin EIP-1822 的实现与 EIP-1822 中的定义不一致,主要是 EIP-1822 中定义的插槽位置与 EIP-1967 中定义的插槽位置不一致导致。openzeppelin 选择使用 EIP-1967 中定义的插槽位置来具体实现。同时 EIP-1822 也有很明显的缺点,即新来的一个实现合约中只实现了 proxiableUUID 方法,没有实现 updateCodeAddress 方法,则合约就无法继续升级,导致所有的代理合约都锁死。所以 openzepplin 在具体实现时,其实现的具体思路是:提供一个 UUPSUpgradeable 合约,在该合约中提供合约升级方法 upgradeTo。 与 EIP-1822 的不同点在于,它取消了 proxiableUUID 这个 flag,增加了 _autorizeUpgrade 方法,用于授权一个新地址。同时提供了一个 upgradeToAndCall 方法,用于升级后马上进行初始化操作。openzeppelin 通过回滚检测,来检查是否升级成功。
Transparent 透明代理模式
openzeppelin 开发库中 TransparentUpgradeableProxy 合约实现了一个可由管理员升级的代理。Transparent 透明代理模式实现可升级合约需要逻辑合约、管理合约、代理合约等三份合约。逻辑合约负责合约的业务逻辑。管理合约负责管理合约的升级。代理合约是 DAPP 直接交互的合约。
先部署逻辑合约并记录部署后的合约地址,逻辑合约中有个 initialize 方法,ABI 编码为 0x8129fc1c。然后部署管理合约 ProxyAdmin,并记录下管理合约的地址。最后部署代理合约 TransparentUpgradeableProxy,代理合约需要逻辑合约地址、管理合约地址、逻辑合约的 initialize 方法 ABI 编码 0x8129fc1c 作为参数才能部署,代理合约是 DAPP 调用的合约。例子源码 https://github.com/PandaManPMC/dapp/blob/main/transparent/ ,管理合约和代理合约有标准的实现。
openzeplin 的透明代理合约,在 fallback 函数和 proxy 中的其它函数中添加一个路由,以此确定合约的正确调用避免出现函数选择器碰撞的错误。确保用户只能够调用代理合约中的 fallback 函数,而 admin 不能够调用代理合约中的 fallback 函数,用户在调用到代理合约的其它函数时,会被自动转向到 fallback 函数中去。透明代理合约升级模式有一个缺点,每一次调用时都有一个 msg.sender != admin 判断的 sload 操作,提高了操作的 gas 费用。
ProxyAdmin 管理合约,充当所有代理的所有者。启动项目时,ProxyAdmin 归部署者地址所有,但可以通过调用 transferOwnership 来转移其所有权,源码 https://github.com/OpenZeppelin/openzeppelin-contracts/blob/master/contracts/proxy/transparent/ProxyAdmin.sol 。
代理合约 TransparentUpgradeableProxy 实现了 ERC1967,它会作为存储层,DAPP 通过逻辑合约的 ABI 和代理合约的地址实例化合约实例进行调用。源码 https://github.com/OpenZeppelin/openzeppelin-contracts/blob/master/contracts/proxy/transparent/TransparentUpgradeableProxy.sol 。
逻辑合约
逻辑合约是可升级合约,可升级合约可以新增存储项但不能修改顺序,合约存储不能乱,因为最终数据是存储在代理合约中。使用 initialize 替代构造函数,继承的父合约也需要能满足可升级条件,如 Product 合约中不继承 Ownable,而是继承支持升级的 OwnableUpgradeable,也可使用 OpenZeppelin 插件验证合约是否为可升级合约,以及升级时是否有冲突。
web3.js 调用
DAPP 调用代理合约,但使用逻辑合约的 ABI 编码实例化合约对象。例子 https://github.com/PandaManPMC/dapp/blob/main/transparent/index.html。
实现升级
合约升级先部署新的逻辑合约,然后调用 ProxyAdmin 合约进行升级。ProxyAdmin 提供两个方法进行升级,upgrade 方法需要传入 proxy 地址和新的逻辑实现地址。upgradeAndCall 方法需要传入 proxy 地址、新的逻辑实现地址和初始化调用数据。在第一次部署使用时已经调用 upgradeAndCall 初始化过,后面调用 upgrade 方法进行更新。
复制一份新合约并对 getProductPrice 方法做稍微的改动,部署后调用 ProxyAdmin 合约的 upgrade 方法传入地理合约地址、逻辑合约地址进行更新。之后再调用 getProductPrice 方法,数据都在。
UUPS 通用可升级代理 EIP-1822
EIP1822 定义了一个通用的可升级代理合约标准,UUPS 与透明代理合约不同,其合约升级逻辑放在了实现合约侧,而不是代理合约侧。UUPS 在 proxy 合约中,将实现合约的地址固定的存储在一个 slot 插槽中。在实现合约方面,设计一个可代理合约 UUPSProxiable,其主要作用是更新该 slot 插槽中的逻辑合约地址的值。然后,所有的逻辑合约都继承自 UUPSProxiable 合约。
这样设计的优点在于,通过在逻辑实现合约上定义所有函数,它可以依靠 Solidity 编译器来检查任何函数选择器的冲突,此外,代理合约会更小,部署成本更低,在每次调用中减少从存储中的读取,增加了更少的开销。它也存在一个重大的缺点,如果代理合约 proxy 被升级到一个没有继承 UUPSProxiable 合约实现可升级功能的逻辑合约,它就会被锁定在那个实现上,无法继续升级。
UUPS 模式实现的可升级合约相比透明代理模式少了 ProxyAdmin 合约,初始部署就只需要部署逻辑合约和代理合约 ERC1967Proxy,ERC1967Proxy 源码 https://github.com/OpenZeppelin/openzeppelin-contracts/blob/v4.6.0/contracts/proxy/ERC1967/ERC1967Proxy.sol 。例子源码 https://github.com/PandaManPMC/dapp/tree/main/uups 。合约部署顺序也是先部署逻辑合约,再将逻辑合约的地址和 initialize 方法的ABI 编码 0x8129fc1c 作为参数部署代理合约 ERC1967Proxy。
UUPS 逻辑合约实现
基于 UUPS 实现可升级合约一般会选择继承 UUPSUpgradeable ,在 UUPSUpgradeable 的基础上完成业务开发。UUPSUpgradeable 中有 upgradeTo、upgradeToAndCall 等方法的实现,可以更新逻辑合约。DAPP 调用则与透明代理模式相同,使用逻辑合约的 ABI 编码和代理合约的地址实例化合约对象。
实现升级
UUPS 模式的升级是直接通过代理调用 upgradeTo 方法传入新的逻辑合约地址完成升级,例子中使用 web3.js 完成升级 myContract.methods.upgradeTo(),源码 https://github.com/PandaManPMC/dapp/blob/main/uups/index.html 。
Last updated