Beacon Proxy Pattern
- 原文链接:https://www.rareskills.io/post/beacon-proxy
- 译者:AI翻译官,校对:翻译小组
- 本文永久链接:learnblockchain.cn/article…
Beacon Proxy(信标代理)是一种智能合约升级模式,其中多个代理使用相同的实现合约,并且所有代理可以在单个交易中升级。本文解释了这种代理模式的工作原理。
我们假设你已经了解了最小代理的工作原理,甚至可能了解 UUPS 或 透明代理。
通常,代理模式使用单个实现合约和单个代理合约。然而,多个代理也可以使用相同的实现。
为了理解为什么我们需要这样做,让我们想象一个完全在链上的游戏。这个游戏希望将每个用户账户存储为一个单独的合约,以便账户可以轻松转移到不同的钱包,并且一个钱包可以拥有多个账户。每个代理在其各自的存储变量中存储账户信息。
你可以通过以下几种方式实现这一点:
使用最小代理标准(EIP1167)并将每个账户部署为克隆
使用 UUPS 或透明代理模式并为每个账户部署一个代理
在大多数情况下,任何一种选择都可以工作,但如果你想为账户添加新功能怎么办?
在最小代理标准的情况下,你将不得不重新部署整个系统并进行社会迁移,因为克隆是不可升级的。
传统代理是可升级的,但你必须一个一个地升级每个代理。对于更多账户来说,这将是昂贵的。
当有很多克隆和代理时,升级它们都是一件麻烦事。
Beacon 模式旨在解决这个问题:它允许你部署一个新的实现合约并同时升级所有代理。
这意味着 Beacon 模式将允许你部署一个新的账户实现并一次性升级所有代理。
从高层次来看,这个标准允许你为每个实现合约创建无限数量的代理,并且仍然能够轻松升级。
顾名思义,这个标准需要一个 Beacon,OpenZeppelin 称之为“UpgradeableBeacon”,并在 UpgradeableBeacon.sol
中实现。
Beacon 是一个智能合约,通过公共函数向代理提供当前的实现地址。 Beacon 是代理关于当前实现地址的真实来源,这就是为什么它被称为“Beacon”。
当代理收到一个传入交易时,代理首先调用 Beacon 上的 view
函数 implementation()
以获取当前的实现地址,然后代理 delegatecalls
到该地址。这就是 Beacon 作为实现来源的工作原理。
任何额外的代理将遵循相同的模式:它们首先使用 implementation()
从 Beacon 获取实现地址,然后 delegatecall
到该地址。
注意:代理知道在哪里调用 implementation()
是因为它们在一个不可变变量中存储了 Beacon 的地址。我们稍后会详细解释这个机制。
这种模式具有高度可扩展性,因为每个额外的代理只需从 Beacon 读取实现地址,然后使用 delegatecall
。
尽管 Beacon Proxy 模式涉及更多的合约,但代理本身比 UUPS 或透明可升级代理更简单。
Beacon 代理总是调用相同的 Beacon 地址以获取当前的实现地址,因此它们不需要关心诸如管理员是谁或如何更改实现地址等细节。
由于所有代理从 Beacon 的存储中获取实现地址,更改存储槽中的地址会导致所有代理 delegatecall
到新地址,立即“重新路由”它们。
要同时升级所有代理:
部署一个新的实现合约
在 Beacon 的存储中设置新的实现地址
设置新的实现地址是通过调用 Beacon 上的 upgradeTo(address newImplementation)
并传递新地址作为参数来完成的。upgradeTo()
是 UpgradeableBeacon.sol
(Beacon)上的两个公共函数之一。另一个公共(视图)函数是我们之前提到的 implementation()
。
注意:upgradeTo()
具有一个 onlyOwner
修饰符,该修饰符在 UpgradeableBeacon.sol
(Beacon)的构造函数中设置。
upgradeTo()
调用一个内部函数 _setImplementation(address newImplementation)
(也在 Beacon 上),该函数检查新的实现地址是否是一个合约,然后将 Beacon 中的地址存储变量 _implementation
设置为新的实现地址。
现在 Beacon 存储中的实现地址已更改,所有代理将读取 Beacon 中的新地址并将其 delegatecall
路由到新的实现。
这种升级方式很简单,因为你只是将 Beacon 和代理“指向”一个新的实现。如果需要回滚更改,你甚至可以将实现指向以前的版本(注意存储冲突)。
为了避免混淆,我们使用术语“BeaconProxy”来指代智能合约代理,并使用“beacon proxy”来指代设计模式。我们现在将讨论 OpenZeppelin 称为“BeaconProxy”的代理合约,并在 BeaconProxy.sol
中实现。
OpenZeppelin BeaconProxy 继承自 Proxy.sol
并添加了更多功能:
它在 _beacon
中存储 Beacon 合约的地址
添加了一个 _getBeacon()
函数以返回 _beacon
变量
_implementation()
函数被重写以调用 _beacon 地址上的 .implementation()
添加了一个构造函数以设置 _beacon
变量,并且 data
参数初始化代理
以下是删除了注释的 OpenZeppelin BeaconProxy 实现
_implementation()
函数被重写,因为Proxy.sol
调用该函数以在 delegatecall 之前检索实现地址。
BeaconProxy 的构造函数有两个目的:
设置_beacon
地址
使用data
初始化代理
这个可选的data
在delegatecall
中用于实现,允许初始化代理的存储。在我们的游戏示例中,这可能意味着使用玩家的初始统计数据初始化账户(代理)。本质上,data 参数充当代理的 Solidity 构造函数:data 在delegatecall
中用于实现,以便实现逻辑可以配置代理存储变量。
为了让区块浏览器知道 BeaconProxy 是一个代理,它需要遵循 ERC-1967 规范。由于它是一个特定的 beacon 代理,它需要将 Beacon 的地址存储在存储槽中:0xa3f0ad74e5423aebfd80d3ef4346578335a9a72aeaee59ff6cb3582b35133d50
,由bytes32(uint256(keccak256('eip1967.proxy.beacon')) - 1)
计算得出。
类似于透明可升级代理,这个存储地址实际上并不被 BeaconProxy 使用。它只是向区块浏览器发出信号,表明该合约是一个 Beacon Proxy。实际的实现地址存储在一个不可变变量中以进行 gas 优化 ;beacon 的地址永远不会改变。
始终使用访问列表交易模式,因为在进行跨合约调用和访问另一个合约的存储时可以节省 gas。具体来说,代理正在调用 beacon 并从存储中获取实现地址。Beacon Proxy 的访问列表基准测试可以在这里查看。
手动部署多个 BeaconProxies 将是一件麻烦事。这就是工厂合约的用武之地。工厂部署新的代理并在其构造函数中设置 beacon 地址。
OpenZeppelin 在其 beacon 模式中不需要或提供标准的工厂合约。然而,在实践中,工厂合约有助于部署新的代理。
下面提供了一个示例工厂。工厂存储 beacon 的地址,并包含一个函数来创建使用该 beacon 的新代理。createBeaconProxy()
函数接受数据作为输入传递给 BeaconProxy 的构造函数。部署代理后,它返回代理的地址。
现在我们了解了如何使用工厂合约部署代理,让我们看看它如何融入整体结构。
这就是设计 beacon 模式所需的所有合约:
实现
Beacon
工厂(可选)
代理
那么我们如何部署整个系统呢?这并不像看起来那么可怕。
OpenZeppelin 为 Hardhat 和 Foundry 提供了一个 Upgrades 插件。只需安装库并调用deployBeacon()
,传递 beacon 合约的参数即可。从那里,可以通过调用deployBeaconProxy()
来部署 BeaconProxies。升级类似:调用upgradeBeacon()
函数并传递新实现的参数。
系统也可以手动部署:
部署实现合约
部署 beacon 合约,并在构造函数中输入实现地址和允许升级实现地址的地址
部署工厂合约
使用工厂部署所需数量的代理
Beacon Proxy 在现实生活中何时会被使用?我为 Kwenta 创建了一个 Beacon Proxy,它在 Optimism 上运行,TVL 超过 2000 万美元。
Beacon Proxy 用于 Kwenta 的归属包。一个“归属包”是一个智能合约,它会慢慢地将代币($KWENTA)释放给协议的特殊利益和核心贡献者。每个人都会得到一个归属包,代币数量和持续时间各不相同(通常为 1-4 年)。要了解更多关于加密货币中的归属,请参见这里 。
为什么特别选择 Beacon Proxy?
它必须易于升级。归属包必须是可升级的,因为它们调用 Kwenta 质押系统上的函数,而质押系统也是可升级的。如果未来质押系统升级,那么归属包上的功能可能不再有效。使归属包可升级可以使它们具有未来适应性
每个包都有相同的归属逻辑(vest()
,stake()
等),但初始化参数不同(代币数量,归属长度)。这部分要求使归属包成为独立的合约或“隔离的”,因为
更简单的开发:为每个人拥有一个可初始化的合约比拥有一个大型合约并使用复杂的映射来跟踪每个人不同的归属包要简单得多。此外,每个包的 $KWENTA 在包创建时会自动质押,这意味着每个人都在累积奖励。如果每个人的包都在一个合约中,那么奖励会混在一起,变得混乱
归属包的所有权可以轻松转移到其他地址或多签。
归属意味着在 Kwenta 质押合约上调用unstake()
。质押合约有一个 2 周的unstake()
冷却期。所以如果每个人的包都在一个合约中,并且一个人归属(进而取消质押),那么至少 2 周内没有其他人可以归属。将包隔离到单独的合约中可以避免这个错误。
归属包必须支持 10+人。这意味着 10+代理
Beacon Proxy 能够做到所有这些而不牺牲任何东西。
克隆可以轻松部署 10+个可初始化的合约,但它们不可升级。
透明和 UUPS 是可升级的,但需要一个一个地升级每个归属包,这将耗费时间并且花费更多 gas。
考虑过钻石代理,但对于这个结构来说太复杂了。
作为一种优化,FactoryBeacon
结合了UpgradeableBeacon.sol
和Factory
合约。这种组合简化了设置并减少了表面积。
这是可能的,因为工厂不需要是一个独立的合约:它只是几行代码,用于部署一个新的 BeaconProxy 并设置其 beacon 地址和初始化数据。
下面是一个结合了工厂和 beacon 合约的示例。通过继承 UpgradeableBeacon
,该合约保留了与常规 beacon 相同的功能,而 createBeaconProxy()
函数增加了工厂功能。此外,不再需要存储 beacon 地址,因为现在可以使用 address(this)
。
尽管如此,整体的“beacon 结构”仍然相同。
每个人调用他们的 BeaconProxy
,其中包含他们特定归属包的所有存储(归属金额、持续时间)。
然后 BeaconProxy
从 FactoryBeacon
获取实现地址,它仍然具有与常规 beacon 相同的功能。
从 FactoryBeacon
获取实现地址后,BeaconProxy
然后 delegatecalls
到 VestingBaseV2
,它只是实现。
请注意,唯一可以调用 FactoryBeacon
的是 adminDAO(一个管理员多签)。管理员是唯一可以创建新的归属包(BeaconProxy
)并将代理升级到新实现的人。
beacon 代理模式允许为一个实现创建多个代理,并能够一次性升级它们。工厂部署新的代理,这些代理使用 delegatecall
到从 beacon 检索到的地址。beacon 作为实现的真实来源。
需要注意的是,与 UUPS 或 透明代理等其他模式相比,beacon 代理模式在设置期间会产生更高的 gas 成本,因为除了代理之外,还必须部署工厂和 beacon。此外,每次调用代理都会产生额外的成本来调用 beacon。这额外的 gas 成本是主要的缺点。如果你需要多个代理,这不一定是一个劣势,因为这正是 beacon 代理模式最有利的时候。更高的 gas 成本是为什么你通常不会看到 beacon 代理模式仅用于一个代理的原因。
虽然 beacons 允许同时升级多个代理,但设置更复杂且成本更高。它需要更多的 gas 并涉及设置额外的合约,使其在开发和审计方面更昂贵。因此,beacon 代理模式只有在你需要大量代理时才有优势。
本文由 Andrew Chiaramonte 撰写(LinkedIn,Twitter)。
如果觉得我的文章对您有用,请随意打赏。你的支持将鼓励我继续创作!