智能合约升级原理01---起源
Posted 快活林高老大
tags:
篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了智能合约升级原理01---起源相关的知识,希望对你有一定的参考价值。
3年前就学习过一遍智能合约升级架构和原理,当时只初步了解到了思路和delegatecall用法,缺少更多的实践知识。现在重点研究BSN-DDC平台上的数藏合约源码,这次代码研读中又遇到了大量的合约升级代理,下定决心,埋头苦看,终于理顺了升级的原理、来龙去脉、以及如何与ERC721合约相结合的。 对于本文有帮助的文章: 全面理解智能合约升级 https://xiaozhuanlan.com/topic/1762043598 这是我看到关于合约升级及治理写的最好的好文章,有点长,但读完必定有收获。原文来自 OpenZeppelin首席开发人员 Santiago Palladino 关于合约升级的报告,本文详细讨论了当前各种升级方式的原理、各自的优缺点,同时列举了采用相应方案的项目,以便大家进行代码级的参考。在最后一部分,作者还提出了多种配合升级的治理方案。 我们知道,以太坊上的智能合约是不能升级的,因为代码是不可变的,一旦部署就不能更改。但第一次写出完美的代码是很难的,作为人类,我们都很容易犯错。有时,即使是经过审计的合同,也会发现有错误,导致其损失数百万。 在本文中,我们将学习一些可以在Solidity中使用的设计模式,以编写可升级的智能合约。1 什么是智能合约升级?
智能合约升级是一种在保留存储和余额的同时,而又可以任意更改在地址中执行代码的操作。 但是在我们深入进行升级之前,我们将介绍一些无需实施全面升级即可更改系统的策略,这些策略可以作为升级的简单补充。2 升级替代方案
有许多策略可用于修改系统而无需完全升级。一个简单的解决方案是通过迁移来更改系统:部署一组新合约,将必要状态从旧合约复制到新合约(有时可以无信任地完成),根据社区共识,让社区开始与新合约进行交互。 本节中列出的升级策略可用于以可预测的方式修改系统,这与升级不同(升级引入新代码几乎没有什么限制)。修改系统这是根据已有的规则来进行管理,在更改时系统的行为更加可预测。让我们研究其中一些策略。参数的配置
简单地调整合约中的一组参数,可修改范围非常有限,以至于我怀疑是否将其包含在此列表中。一个很好的例子是MakerDAO的稳定费率,这是在合约中可设置的数值,它会改变系统的行为。该值经常更改,并且由于其含义很清楚,因此可以放心地执行操作。 但是,重要的是要了解系统对这些参数中设置的极值的反应。任意高昂的费用或零费用都可能导致系统停止运行,甚至使攻击者能够窃取所有资金。在合约中硬编码合理范围的参数值通常是一个好主意,并以此作为保障措施。合约注册表
由多个合约组成的系统可能依赖合约注册中心。每当合约A需要与B进行交互时,它首先会查询注册表以获得B的地址。通过对注册表的修改,管理员可以将B替换为替代实现B',从而改变其行为。 AAVE的早期版本使用了这种模式。 但是,此机制在切换到B'时不会保留B的状态,如果需要手动迁移,则可能会出现问题。此模式的某些版本通过将逻辑和存储合约解耦来缓解这种情况:状态保持在不变的存储合约中,并且只能根据需要更改的业务逻辑合约。我们将在本文后面部分深入探讨逻辑和存储合约分离。 这种模式的另一个缺点是,它也为外部客户端带来了额外的复杂性,这些外部客户端在与系统交互之前也需要调用注册表。可以通过添加具有不可变接口的外部包装接口来减轻这种情况,该包装接口负责管理注册表查找。策略模式
策略模式是更改合约中部分特定功能函数的代码的简便方法。替代在调用合约中实现函数来执行特定功能,而是通过调用单独的合约来处理该任务,通过切换该合约的实现,可以有效地在不同的“策略”之间进行切换。 更改合约中部分特定功能函数的代码。放弃在合约A中实现某特定函数来执行某特定功能,而是采用**在合约A中通过调用未实现某特定功能的接口**,又有其它实现了该接口的特定不同算法的合约B、B`、B``、B```......来处理该任务,通过在合约A中切换合约B、B`、B``、B```......,可以有效地在不同的“策略”之间进行切换。 【合约A中切换接口,例如:function getB (InterfaceB newInterfaceB) … ,其中newInterfaceB在函数体中为一个address】 Compound 就是一个很好的例子,它具有不同的RateModel实现计算利率及其CToken合约可以在它们之间切换。由于已知更改仅限于系统的特定部分,这可以轻松地推出修复程序或在费率计算上改进gas 消耗。当然,一个恶意利率模型实现可以设置为始终还原和停止系统,或为特定帐户提供任意高的利率。尽管如此,限制系统更改的范围仍使对这些更改的推理更加容易。可插拔模块
策略模式的一个更复杂的变体是可插拔模块,其中每个模块都可以向合约添加新函数。在此模型中,主合约提供了一组核心不变的函数,并允许注册新模块。这些模块为核心合约增加了可调用的新函数。这种模式在钱包中最为常见,例如Gnosis Safe或InstaDapp。用户可以选择将新模块添加到自己的电子钱包中,然后每次调用钱包合约时都要求从特定模块执行特定函数。 请记住,此模式要求核心合约没有漏洞。无法通过在此方案中添加新模块来修补管理模块本身上的任何漏洞。此外,根据实现方式的不同,新模块可能有权通过使用委托调用方式(DELEGATECALL,下面会进一步解释)代表核心合约运行任何代码,因此也应仔细检查它们。3 升级模式
在前面不太简短的介绍之后,是时候进入实际的合约升级模式了。这些模式中的大多数都依赖于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一起使用: 新的实现合约需要能接受转账,以防止意外升级到无效合约。 这种模式的好处是,与升级相关的所有逻辑都包含在代理中,并且实现合约不需要任何特殊逻辑即可充当委派目标(除实现合约限制和初始化程序中列出的一些例外)。但是,这种实现模式容易受到函数选择器冲突导致的漏洞的攻击。 主要有三种方式,我们可以替换/升级Implementation Contract:- Diamond Implementation EIP-2535 EIP-2535: Diamonds, Multi-Facet Proxy
- Transparent Implementation EIP-1538 ERC1538: Transparent Contract Standard · Issue #1538 · ethereum/EIPs · GitHub
- UUPS Implementation EIP-1822 EIPs/eip-1822.md at master · ethereum/EIPs · GitHub
4 钻石标准下的多个实现合约
到目前为止,在我们探索的所有代理变体中,每个代理都有一个实现合约的支持。但是,单个代理可以委托多个合约。其首先在 OpenZeppelin 实验室的 vtable 可升级性中有过探讨,这种模式进化成了 Nick Mudge 在 [详见EIP2535]( EIP-2535: Diamonds, Multi-Facet Proxy)提出的钻石合约标准,目前正由 nayms 等项目使用。 在此版本中,代理不存储单个实现地址,而是存储从函数选择器到实现地址的映射。收到调用时,它会查找内部映射(类似于动态分配中使用的 vtable)检索哪个逻辑合约为请求的函数提供实现。 // Sample code, do not use in production! contract Proxy mapping(bytes4 => address) implementations; fallback() external payable address implementation = implementations[msg.sig]; return implementation.delegatecall.value(msg.value)(msg.data);5 选择器冲突和透明代理
以太坊中的所有函数调用都由有效载荷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() |
6 UUPS代理模式
作为透明代理的替代,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编译器检查任何函数选择器冲突。此外,代理的大小要小得多,从而使部署更便宜。在每次调用中,从存储中需要读取的内容更少,降低了开销。 这种模式有一个主要缺点:如果将代理升级到没有可升级函数的实现上,那就永久锁定在该实现上,无法再更改它。一些开发人员更喜欢保持可升级逻辑不变,以防止出现这些问题,而这样做的最佳方式是放在代理合约本身。代理存储冲突和非结构化存储
在所有代理模式变体中,代理合约都需要至少一个状态变量来保存实现合约地址。默认情况下,Solidity存储变量在智能合约存储中的顺序是:声明的第一个变量移至插槽0,第二个变量移至插槽1,依此类推(映射和动态大小数组是此规则的例外)。这意味着,在以下代理合约中,实现合约地址将保存到存储插槽零。 // Sample code, do not use in production! contract Proxy address implementation; 现在,如果我们将该代理与以下看似无害的实现合约结合使用,会发生什么? // Sample code, do not use in production! contract Box address public value; function setValue(address newValue) public value = newValue; 遵循Solidity存储布局规则,通过代理对Box.setValue的任何调用都会将newValue存储在存储插槽零中。但是请记住,由于我们正在使用委托调用,因此受影响的存储将是代理的存储,而不是实现合约。因此,调用 Box.setValue 会意外覆盖代理实现地址,我们绝对不希望发生这种情况。 解决此问题的最简单方法是让Box声明一个虚拟的第一个变量。这会将合约的所有变量向下推一格,从而避免冲突。 // Sample code, do not use in production! contract Box address implementation_notUsedHere; address public value; function setValue(address newValue) public value = newValue; 管有效,但它有一个缺点,即要求所有委托目标合约都添加此额外的虚拟变量。这限制了可重用性,因为普通合约不能用作实现合约。这也容易出错,因为很容易忘记在合约中添加该额外变量。 为避免此问题,非结构化存储模式被引入。此模式模仿Solidity如何处理映射和动态大小的数组:它不是将实现地址变量存储在第一个插槽中,而是存储在存储中的任意插槽中,确切地说是0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc。由于合约的可寻址存储大小为2 ^ 256,因此发生冲突的机会实际上为零。 // Sample code, do not use in production! contract Proxy fallback() external payable address implementation = sload(0x360894...382bbc); implementation.delegatecall.value(msg.value)(msg.data); 这样,实现合约业务逻辑将使用存储的第一个插槽,而代理将使用更高的插槽以避免任何冲突。出于工具性目的, EIP1967中已对委托调用代理所使用的插槽进行了标准化。这允许诸如 Etherscan浏览器能轻松识别这些代理(因为在该特定插槽中具有类似地址值的任何合约很可能是代理)并解析出对应的合约的地址。这种模式有效地解决了实现合约中的任何存储冲突问题,除了代理实现的额外复杂性外,没有任何缺点。UUPS代理模式的问题
现在的问题是,因为upgradeTo函数存在于Implementation contract的一侧,开发者必须担心这个函数的实现,这有时可能很复杂,而且因为增加了更多的代码,增加了攻击的可能性。这个函数也需要在所有被升级的Implementation contract的版本中出现,这就引入了一个风险,如果开发者忘记添加这个函数,那么合同就不能再被升级了。 这个问题使用OpenZepplin的库代码解决,只要继承这个类就OK啦。智能合约--如何实现可升级的智能合约
一. 什么是智能合约
智能合约通俗点说就是写在区块链上面的代码,代码里面编写着严谨完善的规则,一旦某个用户满足了合约里面的规则条件,就会触发里面的代码,执行某个方法。
二. 为什么要使智能合约达到可升级
智能合约的特点之一就是部署到链上之后不能修改,这一机制使得合约的交互方都可以信任合约。但也带来了一系列的问题,并且如果已部署的合约发现漏洞,也是无法修复的。假如发现了bug,致命性的,必须修复,那如何处理? 就是使用合约达到可升级优化才能满足需求
三. 升级合约的机制原理
- 什么是合约升级
使已经部署上链的合约做到可优化可更改,例如链上的业务逻辑代码和状态变量达到可增删改的功能.
2. 合约升级的实现机制原理
目前实现的方式根据存储区分有各种各样的模式,但是都离不开一个最底层的机制,就是使用delegatecall的特性去实现可升级的合约,达到合约可持续优化更改的效果.
delegatecall 介绍
目前调用合约的方式主要有三种
- call
- delegateCall
- staticCall
共同点:都是去调用执行目标合约地址的方法
区别:delegateCall的执行环境和call和staticCall相反,正因为这样所以可利用这种特性实现可升级,在用户层面上无感知。
具体的delegateCall的介绍可以看我另外一篇文章
Solidity--call、delegatecall 和 callcode 的区别_Zeke Luo的博客-CSDN博客
四. 实现可升级的ERC20合约
代码概述
- 编写InitializedProxy代理合约,此合约主要作用是转发和存储数据.
继承openzeppelin的StorageSlotUpgradeable合约,用于插槽工具类。
// SPDX-License-Identifier: GPL-3.0
import "@openzeppelin/contracts-upgradeable/utils/StorageSlotUpgradeable.sol";
pragma solidity >=0.7.0 <0.9.0;
contract InitializedProxy
// address of logic contract
// slot bytes32(uint256(keccak256('EIP1967.PROXY.CONFTI.IMPLEMENTATION')) - 1)
bytes32 internal constant _IMPLEMENTATION_SLOT = 0x5f62ce3c9aebd463c7a36ab1b244d2bb94f07a2c13889b3b687940ebc467b9b3;
// ======== Constructor =========
constructor(
address logic,
bytes memory initializationCalldata
)
require(logic != address(0),"Proxy :: Wrong proxy contract address");
StorageSlotUpgradeable.getAddressSlot(_IMPLEMENTATION_SLOT).value = logic;
// Delegatecall into the logic contract, supplying initialization calldata
(bool _ok, bytes memory returnData) =
logic.delegatecall(initializationCalldata);
// Revert if delegatecall to implementation reverts
require(_ok, string(returnData));
// ======== Fallback =========
fallback() external payable
address _impl = StorageSlotUpgradeable.getAddressSlot(_IMPLEMENTATION_SLOT).value;
assembly
let ptr := mload(0x40)
calldatacopy(ptr, 0, calldatasize())
let result := delegatecall(gas(), _impl, ptr, calldatasize(), 0, 0)
let size := returndatasize()
returndatacopy(ptr, 0, size)
switch result
case 0
revert(ptr, size)
default
return(ptr, size)
// ======== Receive ===
receive() external payable // solhint-disable-line no-empty-blocks
function upgradeVersion(address newAddress_) public
StorageSlotUpgradeable.getAddressSlot(_IMPLEMENTATION_SLOT).value = newAddress_;
1.constructor构造函数拥有初始化数据,并且保存指向的业务逻辑合约
2.fallback转发接收所有业务逻辑合约的方法,
3.upgradVersion 用于升级的方法
替换指定插槽的旧逻辑合约地址,更换新的逻辑合约
- 实现自己的业务逻辑合约(可升级的erc20)
import "@openzeppelin/contracts-upgradeable/token/ERC20/ERC20Upgradeable.sol";
contract logicA is ERC20Upgradeable
function initialize(string memory tokenName_ ,string memory symbol_) initializer external
__ERC20_init(tokenName_, symbol_);
function mint(address account,uint256 amount) external
if(account != address(0) && amount > 0)
_mint(account,amount);
function burn(address account,uint256 amount) external
if(account != address(0) && amount > 0)
_burn(account,amount);
- 部署工厂合约
此合约的主要作用是,创建可升级的逻辑合约,并且管理升级等.
contract testtFactory
address public logicProxy;
function createProxy(address logiAddress_,string memory tokenName_,string memory symbol_) public
bytes memory _initializationCalldata = abi.encodeWithSignature(
"initialize(string,string)",
tokenName_,
symbol_
);
logicProxy = address (new InitializedProxy(logiAddress_,_initializationCalldata));
function updateLogicProxy(address updataTemplate_) public
(bool _ok, bytes memory returnData) = logicProxy.call(abi.encodeWithSignature(
"upgradeVersion(address)",
updataTemplate_
));
require(_ok, string(returnData));
createProxy : 生成可升级的代理合约
updateLogicProxy : 升级合约
- 部署V2合约也就是升级之后的合约
contract logicA2 is ERC20Upgradeable
function mint(address account,uint256 amount) external
require (amount <= 10 ,"must be <= 10" );
if(account != address(0) && amount > 0)
_mint(account,amount);
function burn(address account,uint256 amount) external
if(account != address(0) && amount > 0)
_burn(account,amount);
此合约修改了mint的金额必须需要小于等于10,用于升级之后的逻辑检验。
五. 以上代码的使用逻辑介绍
以remix做案例使用:
一.部署业务逻辑合约(可升级erc20合约)
第二步.部署工厂合约
第三步调用工厂合约创建可升级的erc20Token合约
调用createProxy传入第一步创建的可升级erc20合约地址
创建成功之后,点击logicProxy查看生成之后的代理地址
然后调用at方法,并且选择相应的逻辑合约即可调用.(at使用方式和原理可自行查看)
第四步 升级当前的erc20合约
打开工厂合约调用updateLogicProxy传入新合约的地址,即可完成升级.
(用户无感升级)
五. 升级逻辑
⚠️ 升级注意事项
1.插槽的冲突风险
2.升级之后继承关系
总结
合约升级风险会比较大,尽量严谨,并且升级要做到只增不减不修改.
以上就是今天要讲的内容,本文仅仅简单介绍了delegateCall的升级使用,关于安全方面还是需要自行根据业务去加限制,如有其他不正确的欢迎指出,或者DM
以上是关于智能合约升级原理01---起源的主要内容,如果未能解决你的问题,请参考以下文章