译者推荐:这是我看到关于合约升级及治理写的最好的好文章,有点长,但读完必定有收获。原文来自 OpenZeppelin首席开发人员 Santiago Palladino 关于合约升级的报告,本文详细讨论了当前各种升级方式的原理、各自的优缺点,同时列举了采用相应方案的项目,以便大家进行代码级的参考。在最后一部分,作者还提出了多种配合升级的治理方案。
从技术角度对不同的以太坊智能合约升级模式和策略进行了调查,并提供了一套有关升级管理和治理的良好实践和建议。
对于以太坊开发人员来说,智能合约升级并不是一个新概念。最早的升级模式之一可以追溯到2016年5月的Nick Johnson的gist,是在 4年前的时间,几乎覆盖了整个以太坊的历程(以太坊上线了5年)。
从那时起,智能合约升级工作进行了很多的探索、出现了各种不用的实现方式。升级既可以用作在出现漏洞时进行修复,也可以用作逐步添加新功能来迭代系统开发。
但是,由于智能合约升级带来的技术复杂性以及它们可能对真正的权力下放构成威胁,因此围绕智能合约升级也存在很多争议。在这篇文章中,我们将讨论这两个问题。我们将介绍不同的升级实现,并回顾一些成功的示例,并讨论每个示例的优缺点。然后,我们将回顾一些用于治理和管理的良好实践,以减轻向系统添加升级选项的中心化风险。
让我们首先定义智能合约升级的含义:
什么是智能合约升级? 智能合约升级是一种在保留存储和余额的同时,而又可以任意更改在地址中执行代码的操作。
但是在我们深入进行升级之前,我们将介绍一些无需实施全面升级即可更改系统的策略,这些策略可以作为升级的简单补充。
请坐稳了,这将是一个很长的文章。
有许多策略可用于修改系统而无需完全升级。一个简单的解决方案是通过迁移来更改系统:部署一组新合约,将必要状态从旧合约复制到新合约(有时可以无信任地完成),根据社区共识,让社区开始与新合约进行交互。
本节中列出的升级策略可用于以可预测的方式修改系统,这与升级不同(升级引入新代码几乎没有什么限制)。修改系统这是根据已有的规则来进行管理,在更改时系统的行为更加可预测。让我们研究其中一些策略。
简单地调整合约中的一组参数,可修改范围非常有限,以至于我怀疑是否将其包含在此列表中。一个很好的例子是MakerDAO的稳定费率,这是在合约中可设置的数值,它会改变系统的行为。该值经常更改,并且由于其含义很清楚,因此可以放心地执行操作。
但是,重要的是要了解系统对这些参数中设置的极值的反应。任意高昂的费用或零费用都可能导致系统停止运行,甚至使攻击者能够窃取所有资金。在合约中硬编码合理范围的参数值通常是一个好主意,并以此作为保障措施。
由多个合约组成的系统可能依赖合约注册中心。每当合约A需要与B进行交互时,它首先会查询注册表以获得B的地址。通过对注册表的修改,管理员可以将B替换为替代实现B',从而改变其行为。 AAVE的早期版本使用了这种模式。
但是,此机制在切换到B'时不会保留B的状态,如果需要手动迁移,则可能会出现问题。此模式的某些版本通过将逻辑和存储合约解耦来缓解这种情况:状态保持在不变的存储合约中,并且只能根据需要更改的业务逻辑合约。我们将在本文后面部分深入探讨逻辑和存储合约分离。
这种模式的另一个缺点是,它也为外部客户端带来了额外的复杂性,这些外部客户端在与系统交互之前也需要调用注册表。可以通过添加具有不可变接口的外部包装接口来减轻这种情况,该包装接口负责管理注册表查找。
策略模式是更改合约中部分特定功能函数的代码的简便方法。替代在调用合约中实现函数来执行特定功能,而是通过调用单独的合约来处理该任务,通过切换该合约的实现,可以有效地在不同的“策略”之间进行切换。
Compound 就是一个很好的例子,它具有不同的RateModel实现计算利率及其CToken合约可以在它们之间切换。由于已知更改仅限于系统的特定部分,这可以轻松地推出修复程序或在费率计算上改进gas 消耗。当然,一个恶意利率模型实现可以设置为始终还原和停止系统,或为特定帐户提供任意高的利率。尽管如此,限制系统更改的范围仍使对这些更改的推理更加容易。
策略模式的一个更复杂的变体是可插拔模块,其中每个模块都可以向合约添加新函数。在此模型中,主合约提供了一组核心不变的函数,并允许注册新模块。这些模块为核心合约增加了可调用的新函数。这种模式在钱包中最为常见,例如Gnosis Safe或InstaDapp。用户可以选择将新模块添加到自己的电子钱包中,然后每次调用钱包合约时都要求从特定模块执行特定函数。
请记住,此模式要求核心合约没有漏洞。无法通过在此方案中添加新模块来修补管理模块本身上的任何漏洞。此外,根据实现方式的不同,新模块可能有权通过使用委托调用方式(DELEGATECALL,下面会进一步解释)代表核心合约运行任何代码,因此也应仔细检查它们。
在前面不太简短的介绍之后,是时候进入实际的合约升级模式了。这些模式中的大多数都依赖于EVM原语(DELEGATECALL操作码),因此让我们从其工作原理的简要概述开始。
在常规的CALL-消息调用中,合约A向B发送payload数据(包含函数及参数信息)。合约B响应此payload数据执行其代码,可能会从其自己的存储中读取或写入数据,然后将响应返回给A。当B执行其代码时,它可以访问有关调用本身的信息,例如msg.sender
设置为A。
但是,在DELEGATECALL - 委托调用,虽然执行的代码是合约B的代码,但是执行发生在合约A的上下文中。这意味着任何对存储的读取或写入都会影响A而不是B的存储。此外,msg.sender
被设置为之前调用A的地址。总而言之,此操作码允许合约执行另一个合约中的代码,就像调用内部函数一样。这也是Solidity能调用外部库的原因所在。
有关DELEGATECALL工作原理的更多信息,请查看此Ethernaut Level walthrough来自Nicole Zhu与委托有关的内容,以太坊深度指南由Facundo Spagnuolo,或升级指南,请参阅OpenZeppelin文档。
委托调用打开了代理模式的大门,衍生出了许多变体,首先在ZeppelinOS和AragonOS中流行。如果你想深入了解委托代理合约的技术细节,我强烈建议阅读由Gnosis的Alan Lu写的这篇文章。
在最基本的级别上,此模式依赖于代理合约和实现合约(也称为逻辑合约或委托目标)。代理知道实现合约的地址,并把收到的调用都委托它执行。
// 示例代码,勿在产品中使用
contract Proxy {
address implementation;
fallback() external payable {
return implementation.delegatecall.value(msg.value)(msg.data);
}
}
由于代理在实现中使用了委托调用,因此就好像它自己在运行实现的代码一样。实现代码可以修改自己的存储和余额,并保留了调用的原始msg.sender
。用户始终与代理进行交互,后面的实现合约对用户时不可见的。
这样便可以轻松执行升级。通过更改代理中的实现地址,可以更改每次调用代理时运行的代码,而用户与之交互的地址始终相同。状态也被保留,因为状态被保存在代理合约存储中,而不是在实现合约的存储中。
这种模式还有另一个优势:单个实现合约可以服务多个代理。由于存储保存在每个代理中,因此实现合约仅用于其代码。每个用户都可以部署自己的代理,并指向相同的不可变实现。
但是,这里缺少一些内容:我们需要定义如何实现升级逻辑。每种代理变体有着各自不同的升级逻辑。
合约的升级通常由修改实现合约的函数来处理。在升级模式的某些变体中,代理合约中有管理实现的函数,并且仅限于由管理员调用。
// Sample code, do not use in production!
contract AdminUpgradeableProxy {
address implementation;
address admin;
fallback() external payable {
implementation.delegatecall.value(msg.value)(msg.data);
}
function upgrade(address newImplementation) external {
require(msg.sender == admin);
implementation = newImplementation;
}
}
此版本通常还包含函数用来将代理的所有权转账到其他地址。 Compound 将这种模式与额外的twist一起使用: 新的实现合约需要能接受转账,以防止意外升级到无效合约。
这种模式的好处是,与升级相关的所有逻辑都包含在代理中,并且实现合约不需要任何特殊逻辑即可充当委派目标(除实现合约限制和初始化程序中列出的一些例外)。但是,这种实现模式容易受到函数选择器冲突导致的漏洞的攻击。
以太坊中的所有函数调用都由有效载荷payload前4个字节来标识,称为“函数选择器”。选择器是根据函数名称及其签名的哈希值计算得出的。然而,4字节不具有很多熵,这意味着两个函数之间可能会发生冲突:具有不同名称的两个不同函数最终可能具有相同的选择器。如果你偶然发现这种情况,Solidity编译器将足够聪明,可以让你知道,并且拒绝编译具有两个不同函数名称,但具有相同4字节标识符(函数选择器)的合约。
// 这个合约无法通过编译,两个函数具有相同的函数选择器
contract Foo {
function collate_propagate_storage(bytes16) external { }
function burn(uint256) external { }
}
但是,对于实现合约而言,完全有可能具有与代理的升级函数具有相同的4字节标识符的函数。这可能会导致尝试调用实现合约时,管理员无意中将代理升级到随机地址(注:因为实现合约合约与升级函数4字节标识符相同)。 这个帖子由Patricio Palladino解释了该漏洞,然后Martin Abbatemarco说明如何将其用于做恶.
这个问题可以通过开发用于可升级智能合约的适当工具解决,也可以通过代理本身解决。特别是,如果将代理设置为仅管理员能调用升级管理函数,而所有其他用户只能调用实现合约的函数,则不可能发生冲突。
// Sample code, do not use in production!
contract TransparentAdminUpgradeableProxy {
address implementation;
address admin;
fallback() external payable {
require(msg.sender != admin);
implementation.delegatecall.value(msg.value)(msg.data);
}
function upgrade(address newImplementation) external {
if (msg.sender != admin) fallback();
implementation = newImplementation;
}
}
该模式被称为“透明代理合约”(请勿与EIP1538混淆),在这篇文章中有很好的解释。这是OpenZeppelin升级 (以前称为ZeppelinOS)现在使用的模式。它通常与ProxyAdmin合约结合使用,以允许管理员EOA与管理代理合约进行互动(管理员只能管理代理合约交互)。
让通过一个例子看看是怎么工作的。假定代理具有owner() 函数和upgradeTo()函数,该函数将调用委派给具有owner()和transfer()函数的ERC20合约。下表涵盖了所有导致的情况:
msg.sender | owner() | upgradeto() | transfer() |
---|---|---|---|
管理员 | 返回proxy.owner() | 升级代理 | 回退 |
其他帐户 | 返回erc20.owner() | 回退 | 转发到 erc20.transfer() |
数百个 项目使用此模式进行升级,例如dYdX, PoolTogether, USDC, Paxos,AZTEC和Unlock。
但是,透明代理模式有一个缺点: gas 成本。每个调用都需要额外的从存储中加载admin地址,这个操作在去年的伊斯坦布尔分叉之后变得更加昂贵。此外,与其他代理相比,该合约本身的部署成本很高, gas 超过70万。
作为透明代理的替代,EIP1822定义了通用的可升级代理标准,或简称为“ UUPS”。该标准使用相同的委托调用模式,但是将升级逻辑放在实现合约中,而不是在代理本身中。
请记住,由于代理使用委托调用,因此实现合约始终会写入代理的存储中,而不是写入自己的存储中。实现地址本身保留在代理的存储中。并且修改代理的实现地址的逻辑同样在实现逻辑中实现。 UUPS建议所有实现合约都应继承自基础的“可代理proxiable”合约:
// Sample code, do not use in production!
contract UUPSProxy {
address implementation;
fallback() external payable {
implementation.delegatecall.value(msg.value)(msg.data);
}
}
abstract contract UUPSProxiable {
address implementation;
address admin;
function upgrade(address newImplementation) external {
require(msg.sender == admin);
implementation = newImplementation;
}
}
这种方法有几个好处。首先,通过在实现合约上定义所有函数,它可以依靠Solidity编译器检查任何函数选择器冲突。此外,代理的大小要小得多,从而使部署更便宜。在每次调用中,从存储中需要读取的内容更少,降低了开销。
这种模...
如果觉得我的文章对您有用,请随意打赏。你的支持将鼓励我继续创作!