
登链社区上有对应的翻译:
第 1 部分 - 函数选择器深入分析[5]
第 2 部分 - EVM 中的内存[6]
第 3 部分 - EVM 中的存储[7]
第 4 部分 - Go Ethereum(Geth)客户端的存储操作码[8]
第 5 部分 - 委托调用深入研究[9]
第 6 部分 - 交易收取和事件[10]现在让我们以智能合约开发中最常见的模式之一“代理”开始本系列。 历史了解给定“智能合约设计模式”背后的历史非常有价值。这阐明了它为什么出现,它解决了什么具体问题以及沿途做出的设计权衡。 为什么存在这种模式?对于每种模式,我们应该从一个简单的问题开始。 “为什么?”。 为什么创建这种模式,它解决了什么问题? 对于“代理”来说,为什么它来源于是智能合约不可变的。合约是不可变的,这阻止了合约部署后对业务逻辑的任何更新。这引发了一个明显的问题。 我们如何升级我们的智能合约? 这个问题最初是通过“合约迁移”来解决的。合约的新版本将被部署,并且所有状态和余额将需要被转移到这个新实例。 这种方法的一个明显缺点是,新部署会导致新的合约地址。对于集成到更广泛生态系统中的应用程序,这将要求所有第三方也更新其代码库以指向新合约。 另一个缺点是将状态和余额转移到这个新实例的操作复杂性。这不仅会在 Gas 方面非常昂贵,而且还将是一个非常敏感的操作。不正确地更新新合约的状态可能会破坏功能并导致安全漏洞。 显然需要一个更简单的解决方案。我们如何在不更改其地址的情况下更新合约的基础逻辑?我们如何最小化操作开销? 从这些问题中形成了“代理模式”。 最初的代理(委托代理)最初的代理,称为委托代理,使用了一些简单的想法来回答这些问题。 首先,我们需要将业务逻辑和数据存储分开到不同的合约中。这是通过两个合约实现的,“代理合约”用于数据,“实现合约”(也称为逻辑合约)用于业务逻辑。


- “代理”和“实现”之间的存储冲突
- “实现”的不同版本之间的存储冲突
这并不是一个不太可能发生的情况,“代理”可能会有“实现”没有的变量。例如,“代理”需要在某个存储槽中存储“实现”合约的地址。 “实现”合约不应该覆盖"实现"地址槽,如果它这样做了,它将有效地破坏了“代理”。这是一个如此常见的问题,以至于创建了一个标准,ERC-1967[14]。 这定义了一个特定的存储槽(编译器永远不会分配到的一个存储槽),“The Implementation”地址应该存储在其中。 实现版本存储冲突当升级“实现”合约时,状态变量的顺序或类型的更改可能会导致存储槽被重新分配。 看下面的例子。“代理”和“实现”之间的存储冲突



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

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



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

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

- 减少了用户的 gas 开销。
- 消除了 ProxyAdmin 合约的需要。
- upgradeAndCall 逻辑本身变得可升级,因为它在实现中,而实现可以被升级。这包括最终删除它并确立合约的当前状态。
- 减少了关注点的分离,你的实现合约现在处理你的授权升级逻辑和业务逻辑。
- 更新实现合约时增加了风险。由于你的实现现在包含了你的授权升级逻辑,每次升级都可能改变你的授权升级逻辑的攻击面。
- 代理“变砖”的风险。如果不小心升级了不包含 upgradeAndCall 函数的实现合约,代理的升级功能将永远丢失。

