本文介绍了智能合约开发人员需要掌握的关键技能,包括智能合约设计模式和以太坊虚拟机的理解。其中一个常见的模式是“代理”,它解决了智能合约升级问题。为了解决存储冲突和函数签名冲突,创建了标准ERC-1967和透明代理。文章还介绍了委托调用和UUPS代理,以及最小代理和信标代理模式。这些模式可以减少gas成本和操作负担,但也需要注意安全性和信任问题。
了解如何高效和安全地设计大型智能合约项目是智能合约开发人员的关键技能。为了有效地做到这一点,我们利用了各种“智能合约模式”。
本系列“智能合约设计模式”将探讨其中一些模式,深入探讨它们的起源、应用和基本原理。我们将深入探讨传统架构和开发原则如何转化为智能合约世界。
每种新模式都是一种工具,可以帮助你构建和维护去中心化应用程序。
在我们深入研究这些模式之前,一个非常有用的前提条件是对以太坊虚拟机(EVM)有扎实的理解。我们之前的系列 “EVM 深入研究”[4] 探讨了 EVM 的底层实现,我强烈建议在开始之前阅读该系列:
登链社区上有对应的翻译:
第 1 部分 - 函数选择器深入分析[5]
第 2 部分 - EVM 中的内存[6]
第 3 部分 - EVM 中的存储[7]
第 4 部分 - Go Ethereum(Geth)客户端的存储操作码[8]
第 5 部分 - 委托调用深入研究[9]
第 6 部分 - 交易收取和事件[10]
现在让我们以智能合约开发中最常见的模式之一“代理”开始本系列。
了解给定“智能合约设计模式”背后的历史非常有价值。这阐明了它为什么出现,它解决了什么具体问题以及沿途做出的设计权衡。
对于每种模式,我们应该从一个简单的问题开始。
“为什么?”。
为什么创建这种模式,它解决了什么问题?
对于“代理”来说,为什么它来源于是智能合约不可变的。合约是不可变的,这阻止了合约部署后对业务逻辑的任何更新。这引发了一个明显的问题。
我们如何升级我们的智能合约?
这个问题最初是通过“合约迁移”来解决的。合约的新版本将被部署,并且所有状态和余额将需要被转移到这个新实例。
这种方法的一个明显缺点是,新部署会导致新的合约地址。对于集成到更广泛生态系统中的应用程序,这将要求所有第三方也更新其代码库以指向新合约。
另一个缺点是将状态和余额转移到这个新实例的操作复杂性。这不仅会在 Gas 方面非常昂贵,而且还将是一个非常敏感的操作。不正确地更新新合约的状态可能会破坏功能并导致安全漏洞。
显然需要一个更简单的解决方案。我们如何在不更改其地址的情况下更新合约的基础逻辑?我们如何最小化操作开销?
从这些问题中形成了“代理模式”。
最初的代理,称为委托代理,使用了一些简单的想法来回答这些问题。
首先,我们需要将业务逻辑和数据存储分开到不同的合约中。这是通过两个合约实现的,“代理合约”用于数据,“实现合约”(也称为逻辑合约)用于业务逻辑。
Pura Penataran Agung Lempuyang | Bulgari Resort Bali
接下来,我们可以利用“代理合约”从“实现合约”中访问和使用业务逻辑,以“代理”的存储上下文。
“代理”的回退函数将使用委托调用。回退函数[11]是在合约上调用不存在的函数时执行的函数。这允许合约响应任意以太坊交易。
有了这个,我们可以访问在“代理”中未定义的函数签名,并仍然使用“代理”的存储。
如果你对委托调用不熟悉,请参考这篇文章[12]进行深入了解,对它的深入理解对于理解代理模式至关重要。
简而言之,委托调用允许我们以与 Web2 应用程序使用库相同的方式使用“实现合约”。这种分离允许“代理”升级其业务逻辑,类似于更新软件包版本。
下面的图像显示了这两个合约和用户对它们的函数调用。首先是 v1 实现,然后是 v2 实现。
委托代理是一个重大进步,但它并非没有挑战。
委托代理的问题
新代理架构带来了一些问题。两个关键问题集中在两个合约之间的冲突。存储槽冲突和函数签名冲突。
在 Solidity 中,存储布局由代码中变量声明的顺序确定。在升级过程中对此顺序的更改可能导致存储冲突 - 一个严重的问题,其中数据被错误地读取或覆盖。
如果你对存储槽不熟悉,请阅读这篇文章[13]进行深入研究。
广义上说,这些冲突可以分为两种类型:
代理模式要求“代理”和其“实现合约”共享相同的存储布局。如果不匹配,可能会导致存储冲突。
存储冲突是指两个不同的合约在相同的存储槽上分配了相同的变量。这可能导致变量被错误地读取或覆盖。
假设“代理”在存储槽 0 处有 varA,而“实现”在存储槽 0 处有 varB。你可以看到当从“代理”调用委托调用时,这可能会导致问题。
“代理”和“实现”之间的存储冲突
这并不是一个不太可能发生的情况,“代理”可能会有“实现”没有的变量。例如,“代理”需要在某个存储槽中存储“实现”合约的地址。
“实现”合约不应该覆盖"实现"地址槽,如果它这样做了,它将有效地破坏了“代理”。这是一个如此常见的问题,以至于创建了一个标准,ERC-1967[14]。
这定义了一个特定的存储槽(编译器永远不会分配到的一个存储槽),“The Implementation”地址应该存储在其中。
当升级“实现”合约时,状态变量的顺序或类型的更改可能会导致存储槽被重新分配。
看下面的例子。
很容易看出这种槽重新分配可能会给这个合约带来混乱,并开启许多安全漏洞。一个明显的例子是将所有者变量移动到一个新的槽。
如果合约逻辑试图使用其原始槽 0 访问“所有者”,它将错误地与“rewardMultiplier”交互。
在升级“实现”时需要小心,以确保存储布局不受损害。
另一个主要问题是函数签名冲突。
要理解函数签名冲突,首先必须了解 EVM 如何解释对 solidity 合约的函数调用。
如果需要复习,可以在这里[15]找到这方面的深入概述。
简而言之,在合约内部选择函数是由 4 字节的函数签名确定的。这些签名是从函数的名称和其输入类型派生的。
这些签名的冲突可能会导致函数调用的歧义和安全漏洞。让我们来看其中一个漏洞。
上面的内容突出了“代理”和“实现”合约中的某些函数。
这种漏洞的设置是,一个不可信的代理指向一个受信任的实现合约。
这个不可信的代理在“代理”合约中实现了一个新的函数 collate_propagate_storage(bytes16)。
一个用户来到“代理”进行交互,假设他们听说如果使用它就会有一个关联的空投。他们专注于检查“实现”以验证它是否有任何恶意行为,这是所有业务逻辑的所在地。
“实现”使用了一个受信任且经过彻底测试的标准 OpenZepplin 合约。他们注意到“代理”中的 collate_propagate_storage(bytes16),但并未予以重视。这不是他们将要交互的函数或代码。
现在用户满意地在“代理”上调用 burn(1)来销毁他们的代币之一。当交易上链时,他们看到,他们并没有销毁 1 个代币,而是将 1000 个代币转移到另一个未知账户。
刚刚发生了什么?
让我们来看 burn 和 collate_propagate_storage 的函数签名。
你可以自行检查这里[16] ,将上面的函数名称和输入类型粘贴到 keccak256 模拟器中,查看生成的哈希值。
请注意,完整的哈希值是不同的,但这并不重要,因为我们只需要前 4 个字节匹配。
EVM 看到的内容
当用户调用 burn(uint256)时,发生了以下情况:
(请参阅 tincho 的更深入分析[17] )。
虽然上面的例子突出了一种利用,但当“代理”和“实现”具有相同签名的函数出于非恶意的有效原因时,情况也是如此。
假设两个合约都有一个 updateSettings()函数。当用户尝试调用此函数时,合约如何知道你打算调用“代理”还是“实现”的函数?
这种歧义可能会导致意外错误甚至恶意利用。
这是一个如此严重的问题,以至于创建了一个新的代理来解决这个确切的问题,即透明代理。
透明代理的核心思想是为管理员用户和非管理员用户提供 2 条不同的执行路径。
如果管理员调用合约“代理”,函数将可用。对于其他任何人,所有调用都将通过回退函数委托给“实现”,即使存在匹配的函数签名。
这消除了歧义,管理员可以与“代理”函数交互,非管理员只能与“实现”函数交互。
这种设置的一个缺点是普通用户将无法再访问“代理”的读取方法。例如,访问“实现”地址的 getter。
相反,他们必须使用 web3.eth.getStorageAt(),而 getStorageAt()的问题在于你需要知道存储中的位置。
在 ERC-1967[18] 之前,我们上文提到的标准,各种代理会为“实现”地址实现不同的存储位置。
这意味着 Etherscan 等第三方应用无法识别要检查哪个槽以获取有关“实现”的信息。
ERC-1967[19]通过为“实现”地址提供预定义的存储槽来解决了这个问题。
如果我们查看 Etherscan,它会显示其资源管理器上“代理”和“实现”合约的代码。只有在我们有一个已知的存储槽以获取“实现”地址时,这才是可能的。
ERC-1967 还为“信标地址(beacon address)”(稍后我们将涉及此问题)和“管理员地址”提供了定义的槽,并确保在任何这些槽发生更改时发出事件。
现在让我们来看一下透明代理和 2 个执行路径(管理员和非管理员)的 OpenZepplin 实现,以更好地理解发生了什么。
我们将从用户(非管理员)执行路径开始。
让我们从透明代理的继承结构开始,我们有 3 个合约,其中一个是抽象的。抽象合约类似于抽象类,因为它不能单独实例化,其中至少包含一个没有具体实现的函数(这必须由开发人员定义)。我们的核心代理合约“TransparentUpgradeableProxy”继承自“ERC1967Proxy”,后者继承自“Abstract Proxy”。这些代表了你在上面看到的 3 个合约。
到此,我们已经介绍了用户(非管理员)流程,现在让我们快速看一下透明代理的管理员流程。
管理员流程引入了一个新的合约“代理管理员”和库 ERC1967Utils。下面你将看到它们是如何被使用的。
要理解管理员流程,我们首先需要看看核心代理合约“TransparentUpgradeableProxy”中的_admin 是谁。我们可以看到管理员是在构造函数中设置的。构造函数初始化了一个“ProxyAdmin”合约,并将_admin 设置为 ProxyAdmin 合约地址。这意味着授权的是“ProxyAdmin”合约,而不是 ProxyAdmin 所有者 EOA。
而这就是管理员流程。
概念已经变成了代码,你已经看到了理论如何在 solidity 中实现。这将帮助我们加深对代理工作原理和需要注意的潜在安全漏洞的理解。
然而,透明代理并不是代理模式的最后一次迭代,还有一个我们需要审查的。应该关注透明代理的 gas 使用。
在“代理”中引入了管理员检查意味着管理员需要在每次调用时从存储中加载。Solidity 开发人员会知道从存储中加载是 EVM 中最昂贵的操作码之一。
用户的 gas 开销(因此成本增加)导致了 UUPS 代理(通用可升级代理标准)的开发。
UUPS 代理的关键区别在于将“代理”合约中的“upgradeToAndCall”逻辑从“代理”合约移动到“实现”合约。
这个变化意味着“代理”只是通过委托调用简单地将所有调用转发到“实现”合约。
现在授权在“实现”中,我们不再需要 ProxyAdmin 合约,并且我们减少了每次调用“代理”时检查 msg.sender 是否为“管理员”的 gas 开销。
相反,授权逻辑和随后的管理员地址的 gas 昂贵 SLOAD 只在调用 upgradeToAndCall 时执行。因此,所有非管理员用户调用都避免了这个 SLOAD。
OpenZepplin 提供了一个抽象的 UUPS 合约[21] ,可以作为你的“实现”的基础。它留下一个未定义函数 _authorizeUpgrade(address newImplementation) ,以便你作为开发人员可以实现自己的自定义升级授权方式。这些特性可以被实现为诸如时间锁升级、多重签名升级等功能。
在编写“实现”合约时,一个重要的事项是初始化函数。你应该知道它们存在的原因以及与构造函数的区别。
构造函数用于初始化合约中的状态。它们在合约部署时执行一次,它们的代码不包含在合约的字节码中。
在代理模式中,“代理”(保存状态)和“实现”(保存逻辑)是分开的。因此,在“实现”合约的构造函数中进行的任何状态初始化只影响“实现”合约的存储,而不影响“代理”的存储。
为了解决代理设置中构造函数的限制,使用了初始化器函数。这些函数旨在在“代理”的存储中设置初始状态。
初始化器被设计为通过委托调用从“代理”中执行。这确保了初始化的状态在“代理”的存储中,符合“实现”合约的预期逻辑。
与构造函数类似,初始化器只被执行一次。这通常是通过一种机制来强制执行的,比如一个布尔标志,以防止重新初始化,这可能会导致安全漏洞。
需要注意的一点是,与构造函数不同,初始化器不会自动处理继承。在幕后,初始化器只是一个普通函数,这意味着如果“实现”的父合约中有构造函数,则它们需要在初始化器中显式调用。这与构造函数不同,构造函数会自动调用父构造函数。
在实现可升级的合约时,必须特别注意初始化器函数。确保它们是安全的,并且只能按预期调用对于维护合约的完整性至关重要。
现在回到 UUPS 代理的优势和劣势。
UUPS 有利有弊。
主要优势如下:
劣势包括:
如果你想深入了解 UUPS 抽象实现合约,你可以在这里[22] 。
今天我们的最后一个话题是简要介绍最小代理(也称为克隆)和信标代理。这是你可能在实践中看到的两种代理概念。
最小代理的概念是为部署共享公共逻辑但需要单独存储的合约的多个实例提供了一种简化的方法。
一个例子是 Gnosis Safe 合约,其中每个 Safe 都是独特的,但底层的多签逻辑保持一致。
与为每个新实例重新部署整个逻辑相比,这是一种耗费 gas 且昂贵的方法,最小代理模式涉及部署单个实现合约,然后为每个新实例创建轻量级代理合约。
最小代理不包括可升级性或授权功能,简化了它们的结构,并减少了部署和运行时的 gas 成本。它们一旦部署就是静态且不可变的。
信标代理模式为需要同步更新的多个代理合约引入了一种高效的升级机制。
该设计利用了一个名为“信标(Beacon)”的单独合约,该合约保存了所有关联代理使用的实现地址。然后,每个“代理”只需查询“信标”以检索当前的实现地址,而不是自己保存它。
当需要跨多个代理实例进行更新时,信标代理非常有用。回到我们的 Gnosis Safe 示例,每个用户的 Safe 都是一个代理合约,想象一下需要进行关键更新。
更新每个 Safe(代理)的实现地址将在 gas 方面成本高昂,并且需要与用户(代理的部署者和所有者)进行大量协调才能进行更新。
有了 Beacon 代理,平台的维护者(例如 Gnosis 团队)只需要在“The Beacon”合约中更新实现地址。
指向“The Beacon”的所有代理实例也将被更新。
这不仅节省了 gas(因为实现地址只需要在一个位置更新),而且显著减少了操作负担,因为用户无需执行单独的升级。
当然,唯一的缺点是该模式对实现地址的控制很集中。Beacon 的所有者代表着一个重要的信任点。
为了减轻这种信任并增强安全性,可以实施诸如多重签名钱包和时间锁等机制。
在这篇文章中,我们已经介绍了很多内容,穿越了代理合约错综复杂的景观。希望你已经学到了新的概念,并为你的工具包增加了一种模式。