智能合约设计模式:代理

深入探讨可升级合约的架构

了解如何高效和安全地设计大型智能合约项目是智能合约开发人员的关键技能。为了有效地做到这一点,我们利用了各种“智能合约模式”。

本系列“智能合约设计模式”将探讨其中一些模式,深入探讨它们的起源、应用和基本原理。我们将深入探讨传统架构和开发原则如何转化为智能合约世界。

每种新模式都是一种工具,可以帮助你构建和维护去中心化应用程序。

img

在我们深入研究这些模式之前,一个非常有用的前提条件是对以太坊虚拟机(EVM)有扎实的理解。我们之前的系列 “EVM 深入研究” 探讨了 EVM 的底层实现,我强烈建议在开始之前阅读该系列:

登链社区上有对应的翻译:

现在让我们以智能合约开发中最常见的模式之一“代理”开始本系列。

历史

了解给定“智能合约设计模式”背后的历史非常有价值。这阐明了它为什么出现,它解决了什么具体问题以及沿途做出的设计权衡。

为什么存在这种模式?

对于每种模式,我们应该从一个简单的问题开始。

“为什么?”。

为什么创建这种模式,它解决了什么问题?

对于“代理”来说,为什么它来源于是智能合约不可变的。合约是不可变的,这阻止了合约部署后对业务逻辑的任何更新。这引发了一个明显的问题。

我们如何升级我们的智能合约?

这个问题最初是通过“合约迁移”来解决的。合约的新版本将被部署,并且所有状态和余额将需要被转移到这个新实例。

这种方法的一个明显缺点是,新部署会导致新的合约地址。对于集成到更广泛生态系统中的应用程序,这将要求所有第三方也更新其代码库以指向新合约。

另一个缺点是将状态和余额转移到这个新实例的操作复杂性。这不仅会在Gas方面非常昂贵,而且还将是一个非常敏感的操作。不正确地更新新合约的状态可能会破坏功能并导致安全漏洞。

显然需要一个更简单的解决方案。我们如何在不更改其地址的情况下更新合约的基础逻辑?我们如何最小化操作开销?

从这些问题中形成了“代理模式”。

最初的代理(委托代理)

最初的代理,称为委托代理,使用了一些简单的想法来回答这些问题。

首先,我们需要将业务逻辑和数据存储分开到不同的合约中。这是通过两个合约实现的,“代理合约”用于数据,“实现合约”(也称为逻辑合约)用于业务逻辑。

Pura Penataran Agung Lempuyang | Bulgari Resort Bali

代理模式将逻辑与存储分离

接下来,我们可以利用“代理合约”从“实现合约”中访问和使用业务逻辑,以“代理”的存储上下文。

“代理”的回退函数将使用委托调用。回退函数是在合约上调用不存在的函数时执行的函数。这允许合约响应任意以太坊交易。

有了这个,我们可以访问在“代理”中未定义的函数签名,并仍然使用“代理”的存储。

如果你对委托调用不熟悉,请参考这篇文章进行深入了解,对它的深入理解对于理解代理模式至关重要。

简而言之,委托调用允许我们以与 Web2 应用程序使用库相同的方式使用“实现合约”。这种分离允许“代理”升级其业务逻辑,类似于更新软件包版本。

下面的图像显示了这两个合约和用户对它们的函数调用。首先是 v1 实现,然后是 v2 实现。

img

代理模式 - 当调用函数时的执行流程

委托代理是一个重大进步,但它并非没有挑战。

委托代理的问题

新代理架构带来了一些问题。两个关键问题集中在两个合约之间的冲突。存储槽冲突和函数签名冲突。

存储冲突

在 Solidity 中,存储布局由代码中变量声明的顺序确定。在升级过程中对此顺序的更改可能导致存储冲突 - 一个严重的问题,其中数据被错误地读取或覆盖。

如果你对存储槽不熟悉,请阅读这篇文章进行深入研究。

广义上说,这些冲突可以分为两种类型:

  • “代理”和“实现”之间的存储冲突
  • “实现”的不同版本之间的存储冲突

代理和实现的存储冲突

代理模式要求“代理”和其“实现合约”共享相同的存储布局。如果不匹配,可能会导致存储冲突。

存储冲突是指两个不同的合约在相同的存储槽上分配了相同的变量。这可能导致变量被错误地读取或覆盖。

假设“代理”在存储槽 0 处有 varA,而“实现”在存储槽 0 处有 varB。你可以看到当从“代理”调用委托调用时,这可能会导致问题。

img

“代理”和“实现”之间的存储冲突

这并不是一个不太可能发生的情况,“代理”可能会有“实现”没有的变量。例如,“代理”需要在某个存储槽中存储“实现”合约的地址。

“实现”合约不应该覆盖"实现"地址槽,如果它这样做了,它将有效地破坏了“代理”。 这是一个如此常见的问题,以至于创建了一个标准,ERC-1967

这定义了一个特定的存储槽(编译器永远不会分配到的一个存储槽),“The Implementation”地址应该存储在其中。

实现版本存储冲突

当升级“实现”合约时,状态变量的顺序或类型的更改可能会导致存储槽被重新分配。

看下面的例子。

Original Contract Storage Layout:

address owner; // Slot 0
uint256 totalSupply; // Slot 1
mapping(address => uint256) balances; // Slot 2 (starting point of a dynamic mapping)

Upgraded Contract: 

uint256 rewardMultiplier; // Now at Slot 0
address owner; // Moved to Slot 1
uint256 totalSupply; // Moved to Slot 2
mapping(address => uint256) balances; // Slot 3 (starting point of the dynamic mapping has shifted)

很容易看出这种槽重新分配可能会给这个合约带来混乱,并开启许多安全漏洞。一个明显的例子是将所有者变量移动到一个新的槽。

如果合约逻辑试图使用其原始槽 0 访问“所有者”,它将错误地与“rewardMultiplier”交互。

在升级“实现”时需要小心,以确保存储布局不受损害。

函数冲突

另一个主要问题是函数签名冲突。

要理解函数签名冲突,首先必须了解 EVM 如何解释对 solidity 合约的函数调用。

如果需要复习,可以在这里找到这方面的深入概述。

简而言之,在合约内部选择函数是由 4 字节的函数签名确定的。这些签名是从函数的名称和其输入类型派生的。

这些签名的冲突可能会导致函数调用的歧义和安全漏洞。让我们来看其中一个漏洞。

Proxy Contract:

function collate_propagate_storage(bytes16) external {
        implementation.delegatecall(abi.encodeWithSignature(
            "transfer(address,uint256)", proxyOwner, 1000
        ));
}

Implementation Contract: 

function burn(uint256 value) public virtual {
        _burn(_msgSender(), value);
}

function transfer(address to, uint256 value) public virtual returns (bool) {
        address owner = _msgSender();
        _transfer(owner, to, value);
        return true;
}

上面的内容突出了“代理”和“实现”合约中的某些函数。

这种漏洞的设置是,一个不可信的代理指向一个受信任的实现合约。

这个不可信的代理在“代理”合约中实现了一个新的函数 collate_propagate_storage(bytes16)。

一个用户来到“代理”进行交互,假设他们听说如果使用它就会有一个关联的空投。他们专注于检查“实现”以验证它是否有任何恶意行为,这是所有业务逻辑的所在地。

“实现”使用了一个受信任且经过彻底测试的标准 OpenZepplin 合约。他们注意到“代理”中的 collate_propagate_storage(bytes16),但并未予以重视。这不是他们将要交互的函数或代码。

现在用户满意地在“代理”上调用 burn(1)来销毁他们的代币之一。当交易上链时,他们看到,他们并没有销毁 1 个代币,而是将 1000 个代币转移到另一个未知账户。

img

用户调用“代理”进行 burn(1)

刚刚发生了什么?

让我们来看 burn 和 collate_propagate_storage 的函数签名。

  • burn(uint256) = 0x42966c68
  • collate_propagate_storage(bytes16) = 0x42966c68

你可以自行检查这里 ,将上面的函数名称和输入类型粘贴到 keccak256 模拟器中,查看生成的哈希值。

请注意,完整的哈希值是不同的,但这并不重要,因为我们只需要前 4 个字节匹配。

img

EVM 看到的内容

当用户调用 burn(uint256)时,发生了以下情况:

  • EVM 没有看到对 burn(uint256)的调用,它看到的是对 0x42966c68 的函数调用。
  • 由于这个函数签名在“代理”中存在,作为 collate_propagate_storage(bytes16),调用并未传递给回退函数。
  • 相反,它被传递给 collate_propagate_storage(bytes16)。这反过来又调用了将 1000 个代币转移到代理所有者的转账。

(请参阅 tincho 的更深入分析 )。

虽然上面的例子突出了一种利用,但当“代理”和“实现”具有相同签名的函数出于非恶意的有效原因时,情况也是如此。

假设两个合约都有一个 updateSettings()函数。当用户尝试调用此函数时,合约如何知道你打算调用“代理”还是“实现”的函数?

这种歧义可能会导致意外错误甚至恶意利用。

这是一个如此严重的问题,以至于创建了一个新的代理来解决这个确切的问题,即透明代理。

透明代理

透明代理的核心思想是为管理员用户和非管理员用户提供 2 条不同的执行路径。

如果管理员调用合约“代理”,函数将可用。对于其他任何人,所有调用都将通过回退函数委托给“实现”,即使存在匹配的函数签名。

这消除了歧义,管理员可以与“代理”函数交互,非管理员只能与“实现”函数交互。

img

透明代理的高层视角,我们稍后在文章中深入研究这一点

这种设置的一个缺点是普通用户将无法再访问“代理”的读取方法。例如,访问“实现”地址的 getter。

相反,他们必须使用 web3.eth.getStorageAt(),而 getStorageAt()的问题在于你需要知道存储中的位置。

ERC-1967

ERC-1967 之前,我们上文提到的标准,各种代理会为“实现”地址实现不同的存储位置。

这意味着 Etherscan 等第三方应用无法识别要检查哪个槽以获取有关“实现”的信息。

ERC-1967通过为“实现”地址提供预定义的存储槽来解决了这个问题。

如果我们查看 Etherscan,它会显示其资源管理器上“代理”和“实现”合约的代码。只有在我们有一个已知的存储槽以获取“实现”地址时,这才是可能的。

img

Etherscan:“代理”和“实现”合约都可用

ERC-1967 还为“信标地址(beacon address)”(稍后我们将涉及此问题)和“管理员地址”提供了定义的槽,并确保在任何这些槽发生更改时发出事件。

深入代码

现在让我们来看一下透明代理和 2 个执行路径(管理员和非管理员)的 OpenZepplin 实现,以更好地理解发生了什么。

我们将从用户(非管理员)执行路径开始。

用户访问(非管理员)

img

  1. 让我们从透明代理的继承结构开始,我们有 3 个合约,其中一个是抽象的。抽象合约类似于抽象类,因为它不能单独实例化,其中至少包含一个没有具体实现的函数(这必须由开发人员定义)。我们的核心代理合约“TransparentUpgradeableProxy”继承自“ERC1967Proxy”,后者继承自“Abstract Proxy”。这些代表了你在上面看到的 3 个合约。
  2. 接下来,让我们简要介绍构造函数。构造函数按继承顺序执行,从基类到派生类。这意味着首先将调用“ERC1967Proxy” 构造函数,然后是“TransparentUpgradeableProxy”。“Abstract Proxy” 合约没有构造函数。继承的合约构造函数会自动调用,但如果它们接受参数,我们必须显式调用它们,比如“ERC1967Proxy”就是这样。这就是为什么我们在“TransparentUpgradeableProxy”构造函数中有 ERC1967Proxy(_logic, _data)。这是显式调用具有特定输入参数的构造函数的语法。
  3. 现在让我们开始实际的函数调用。非管理员用户将调用“TransparentUpgradeableProxy”。请注意,上述 3 个合约中的每个函数都是私有的(以_前缀表示),因此任何调用都将传递到 fallback( )函数。请注意_fallback( )是私有的,而 fallback( )是实际的回退函数。在“TransparentUpgradeableProxy”中没有 fallback( ),但在继承的“Abstract Proxy”合约中有。这将是我们的入口点。
  4. fallback( )函数只是将我们传递到内部的_fallback( )函数。_fallback( )函数在“Abstract Proxy”合约和“TransparentUpgradeableProxy”合约中都存在。由于“TransparentUpgradeableProxy”是派生合约,它的_fallback( )会覆盖“Abstract Proxy”的_fallback( ),因此调用将进入此处。
  5. _fallback( )中,有一些检查以查看用户是否是管理员用户,因为我们不是管理员,我们将被传递到 super._fallback( )super 是一个关键字,用于调用父类中的函数,在我们的情况下是“ERC1967Proxy”。
  6. 由于“ERC1967Proxy”不包含_fallback( )函数,我们需要上升一级到“Abstract Proxy”中的_fallback( )_fallback( )随后调用_delegate(_implementation( )),其中_implementation( )返回实现合约的地址。
  7. _delegate( )实现利用一些内联汇编来进行委托调用。 (请参阅 evm.codes 以了解每个操作码的详细信息)
    1. 第 27 行:calldatacopy(destOffset = 0, srcOffset = 0, length = calldatasize())
      1. 这将 calldata 从偏移量 0 开始,长度为 calldatasize() 复制到内存中的偏移量 0,以便在委托调用中使用。
    2. 第 31 行:delegatecall(g = gas(), a = implementation, in = 0, insize = calldatasize(), out = 0, outsize = 0)
      1. g = gas - 要随调用一起发送的 Gas 数量。这必须足够用于执行。
      2. a = address - 委托调用的合约地址,在我们的情况下是实现合约。
      3. in = 输入(input)的起始内存位置 - 这标记着将发送到目标合约的输入数据在内存中的起始位置,记住 calldatacopy 复制到内存位置 0。
      4. insize = 输入大小 - 输入数据的大小(以字节为单位),在我们的情况下是 calldatasize(),因为我们要将所有内容传递给它。
      5. out = 输出的起始内存位置 - 标记着委托调用的输出数据将存储在内存中的起始位置,选择位置 0。
      6. outsize = 输出大小 - 内存中输出区域的大小(以字节为单位),在我们的情况下为 0,这意味着不会将任何内容存储在内存中。
      7. 请注意,委托调用的输出值(而不是结果)将存储在返回数据缓冲区中。这可以使用 returnDataCopy 来访问。这意味着即使我们没有将其保存到内存中,返回值仍然可用。
    3. 变量“result”捕获了委托调用是否成功执行的信息。0 表示执行失败。
    4. 第 34 行:returndatacopy(destOffset = 0, srcOffset = 0, length = returndatasize())
      1. 这将返回数据缓冲区的内容从偏移量 0 开始,长度为 returndatasize()(其中包含我们的委托调用的输出)复制到内存中的偏移量 0。
      2. 你可能已经注意到 returndatacopy()只是将返回数据复制到内存中,并问为什么我们没有在委托调用中使用“out”和“outsize”进行这样的操作。问题在于那时我们不知道返回数据的大小。如果我们知道,我们可以立即通过委托调用将返回数据复制到内存中,从而消除了 returndatacopy()的需要。
    5. 第 36 行:一个 switch 语句,在两种情况下,输出都将通过返回或回退返回给用户,具体取决于委托调用是否成功。
      1. return(offset = 0, size = returndatasize()) - 从偏移量 0 开始,返回指定大小 returndatasize()的内存内容,这是委托调用的输出。
      2. revert(offset = 0, size = returndatasize()) - 与 return 相同,但会回退状态更改,并将未使用的 Gas 返回给调用者。

到此,我们已经介绍了用户(非管理员)流程,现在让我们快速看一下透明代理的管理员流程。

管理员访问

管理员流程引入了一个新的合约“代理管理员”和库 ERC1967Utils。下面你将看到它们是如何被使用的。

img

  1. 要理解管理员流程,我们首先需要看看核心代理合约“TransparentUpgradeableProxy”中的_admin 是谁。我们可以看到管理员是在构造函数中设置的。构造函数初始化了一个“ProxyAdmin”合约,并将_admin 设置为 ProxyAdmin 合约地址。这意味着授权的是“ProxyAdmin”合约,而不是 ProxyAdmin 所有者 EOA。
  2. 由于这个原因,我们从管理员用户的调用必须通过“ProxyAdmin”合约调用,而不是直接调用到“TransparentUpgradeableProxy”。我们必须是“ProxyAdmin”合约的所有者。
  3. 我们调用 upgradeAndCall,传入我们要目标的代理,新实现地址以及该新实现的数据(可选)的调用。这将调用代理上的 upgradeToAndCall。
  4. 如前所述,所有调用最终都将结束在 fallback,因为所有其他方法都是私有的。fallback( ) 然后调用私有的_fallback( )方法。
  5. 我们再次使用 msg.sender 进行管理员检查,但这次我们是_proxyAdmin( )。请注意,_proxyAdmin 只是在_admin 上的一个 getter。现在是提醒你 tx.origin 和 msg.sender 之间的区别的好时机。tx.origin 指的是发起交易的原始外部账户(EOA),在这种情况下是 ProxyAdmin 合约的所有者。msg.sender 是此合约的直接调用者,在这种情况下是 ProxyAdmin 合约。我们通过了管理员检查,然后验证只有特定方法“upgradeToAndCall”正在被调用。如果没有,回退调用,如果是,则调用函数_dispatchUpgradeToAndCall()
  6. _dispatchUpgradeToAndCall()从 calldata 中获取新实现地址,然后使用 ERC1967Utils.UpgradeToAndCall,传入新实现地址和任何后续调用的数据。
  7. ERC1967Utils.UpgradeToAndCall 验证新实现地址处的代码是否为非零,然后使用 ERC-1967 中指定的存储空间更新新实现地址。
  8. 如果数据长度> 0,这意味着用户希望在更新后进行一些调用,因此在新地址进行委托调用。如果数据长度为 0,请验证调用没有附加 ether,这只是为了防止资金被困在合约中。

而这就是管理员流程。


概念已经变成了代码,你已经看到了理论如何在 solidity 中实现。这将帮助我们加深对代理工作原理和需要注意的潜在安全漏洞的理解。

然而,透明代理并不是代理模式的最后一次迭代,还有一个我们需要审查的。应该关注透明代理的 gas 使用。

在“代理”中引入了管理员检查意味着管理员需要在每次调用时从存储中加载。Solidity 开发人员会知道从存储中加载是 EVM 中最昂贵的操作码之一。

用户的 gas 开销(因此成本增加)导致了 UUPS 代理(通用可升级代理标准)的开发。

UUPS 代理

UUPS 代理的关键区别在于将“代理”合约中的“upgradeToAndCall”逻辑从“代理”合约移动到“实现”合约。

这个变化意味着“代理”只是通过委托调用简单地将所有调用转发到“实现”合约。

现在授权在“实现”中,我们不再需要 ProxyAdmin 合约,并且我们减少了每次调用“代理”时检查 msg.sender 是否为“管理员”的 gas 开销。

相反,授权逻辑和随后的管理员地址的 gas 昂贵 SLOAD 只在调用 upgradeToAndCall 时执行。因此,所有非管理员用户调用都避免了这个 SLOAD。

OpenZepplin 提供了一个抽象的 UUPS 合约 ,可以作为你的“实现”的基础。它留下一个未定义函数 _authorizeUpgrade(address newImplementation) ,以便你作为开发人员可以实现自己的自定义升级授权方式。这些特性可以被实现为诸如时间锁升级、多重签名升级等功能。

img

必须在“实现”合约中实现自定义 _authorizeUpgrade

在编写“实现”合约时,一个重要的事项是初始化函数。你应该知道它们存在的原因以及与构造函数的区别。

构造函数 vs 初始化函数

构造函数用于初始化合约中的状态。它们在合约部署时执行一次,它们的代码不包含在合约的字节码中。

在代理模式中,“代理”(保存状态)和“实现”(保存逻辑)是分开的。因此,在“实现”合约的构造函数中进行的任何状态初始化只影响“实现”合约的存储,而不影响“代理”的存储。

为了解决代理设置中构造函数的限制,使用了初始化器函数。这些函数旨在在“代理”的存储中设置初始状态。

初始化器被设计为通过委托调用从“代理”中执行。这确保了初始化的状态在“代理”的存储中,符合“实现”合约的预期逻辑。

与构造函数类似,初始化器只被执行一次。这通常是通过一种机制来强制执行的,比如一个布尔标志,以防止重新初始化,这可能会导致安全漏洞。

需要注意的一点是,与构造函数不同,初始化器不会自动处理继承。在幕后,初始化器只是一个普通函数,这意味着如果“实现”的父合约中有构造函数,则它们需要在初始化器中显式调用。这与构造函数不同,构造函数会自动调用父构造函数。

在实现可升级的合约时,必须特别注意初始化器函数。确保它们是安全的,并且只能按预期调用对于维护合约的完整性至关重要。

现在回到 UUPS 代理的优势和劣势。

优势和劣势

UUPS 有利有弊。

主要优势如下:

  • 减少了用户的 gas 开销。
  • 消除了 ProxyAdmin 合约的需要。
  • upgradeAndCall 逻辑本身变得可升级,因为它在实现中,而实现可以被升级。这包括最终删除它并确立合约的当前状态。

劣势包括:

  • 减少了关注点的分离,你的实现合约现在处理你的授权升级逻辑和业务逻辑。
  • 更新实现合约时增加了风险。由于你的实现现在包含了你的授权升级逻辑,每次升级都可能改变你的授权升级逻辑的攻击面。
  • 代理“变砖”的风险。如果不小心升级了不包含 upgradeAndCall 函数的实现合约,代理的升级功能将永远丢失。

如果你想深入了解 UUPS 抽象实现合约,你可以在这里

今天我们的最后一个话题是简要介绍最小代理(也称为克隆)和信标代理。这是你可能在实践中看到的两种代理概念。

最小代理

最小代理的概念是为部署共享公共逻辑但需要单独存储的合约的多个实例提供了一种简化的方法。

一个例子是 Gnosis Safe 合约,其中每个 Safe 都是独特的,但底层的多签逻辑保持一致。

与为每个新实例重新部署整个逻辑相比,这是一种耗费 gas 且昂贵的方法,最小代理模式涉及部署单个实现合约,然后为每个新实例创建轻量级代理合约。

img

Uniswap V3 Pools、Gnosis Safes 和 Yearn Vaults 都是使用最小代理的协议的例子

最小代理不包括可升级性或授权功能,简化了它们的结构,并减少了部署和运行时的 gas 成本。它们一旦部署就是静态且不可变的。

信标代理

信标代理模式为需要同步更新的多个代理合约引入了一种高效的升级机制。

该设计利用了一个名为“信标(Beacon)”的单独合约,该合约保存了所有关联代理使用的实现地址。然后,每个“代理”只需查询“信标”以检索当前的实现地址,而不是自己保存它。

img

代理引用“信标”以获取实现地址

当需要跨多个代理实例进行更新时,信标代理非常有用。回到我们的 Gnosis Safe 示例,每个用户的 Safe 都是一个代理合约,想象一下需要进行关键更新。

更新每个 Safe(代理)的实现地址将在 gas 方面成本高昂,并且需要与用户(代理的部署者和所有者)进行大量协调才能进行更新。

有了 Beacon 代理,平台的维护者(例如 Gnosis 团队)只需要在“The Beacon”合约中更新实现地址。

指向“The Beacon”的所有代理实例也将被更新。

这不仅节省了 gas(因为实现地址只需要在一个位置更新),而且显著减少了操作负担,因为用户无需执行单独的升级。

当然,唯一的缺点是该模式对实现地址的控制很集中。Beacon 的所有者代表着一个重要的信任点。

为了减轻这种信任并增强安全性,可以实施诸如多重签名钱包和时间锁等机制。

在这篇文章中,我们已经介绍了很多内容,穿越了代理合约错综复杂的景观。希望你已经学到了新的概念,并为你的工具包增加了一种模式。


本翻译由 DeCert.me 协助支持, 在 DeCert 构建可信履历,为自己码一个未来。

点赞 0
收藏 1
分享
本文参与登链社区写作激励计划 ,好文好收益,欢迎正在阅读的你也加入。

0 条评论

请先 登录 后评论
翻译小组
翻译小组
0x9e64...7c84
大家看到好的文章可以在 GitHub 提 Issue: https://github.com/lbc-team/Pioneer/issues 欢迎关注我的 Twitter: https://twitter.com/UpchainDAO