技术解析钻石代理合约最佳安全实践

钻石代理合约是以太坊智能合约的一种设计模式,由 EIP-2535 引入。

撰文:Certik

代理合约是智能合约开发者的重要工具。如今,合约系统里已有多种代理模式和对应的使用规则。我们之前已经概述了可升级的代理合约安全最佳实践。

本文我们将介绍了另一种在开发者社区颇受青睐的代理模式,即钻石代理模式。

什么是钻石代理模式 / 合约

钻石代理合约,也被称为「钻石」,是以太坊智能合约的一种设计模式,由以太坊改进提案(EIP)2535 引入。

 

钻石模式通过将合约的功能分割成较小的合约(也被形象地称为「切面」),允许合约拥有无限的功能。钻石充当代理,将函数调用路由到适当的切面。

 钻石模式的设计可以解决以太坊网络的最大合约大小限制问题。通过将一个大型合约分解成较小的切面,钻石模式允许开发人员建立更复杂和功能丰富的智能合约而不受大小限制影响。

 与传统的可升级合约相比,钻石代理提供了巨大的灵活性。它们允许合约部分升级,增加、替换或删除选定的部分函数,而不触及其他部分。

 本文提供了 EIP-2535 的概述,包括与广泛使用的透明代理模式和 UUPS 代理模式的比较,以及它对开发者社区的安全考虑。

定义及详解

在 EIP-2535 的背景下,「钻石」是一个代理合约,其功能实现由不同的逻辑合约提供,称为「切面」。

想像一下,真正的钻石有不同的侧面,叫做切面(facet),那么相应的以太坊钻石合约也有不同的切面。每一个钻石借用功能的合约都是不同的侧面或切面(facet)。

钻石标准使用类比的方式扩展了「钻石切割」的功能 ,用于增加,替换,或删除切面和功能。

此外,钻石标准提供了称为「钻石放大镜 (Diamond Loupe)」的功能,返回关于切面的信息和钻石存在的功能。

与传统的代理模式相比,「钻石」等同于代理合约,而不同的「切面」对应于实现合约。一个钻石代理的不同切面可以共享内部函数、库和状态变量。钻石的关键组成部分如下:

钻石 Diamond

作为代理的中央合约,将函数调用路由到适当的切面。它包含一个函数选择器到「切面」地址的映射。

切面 Facets

实现特定功能的单个合约。每个切面都包含一组可以被钻石调用的函数。

钻石放大镜 Diamond Loupe

是在 EIP-2535 中定义的一组标准函数,提供关于钻石中使用的切面和函数选择器的信息。钻石放大镜允许开发者和用户检查和了解钻石的结构。

钻石切割 DiamondCut

用于添加、替换或删除钻石中的切面及其相应的功能选择器的函数。只有授权的地址(例如,钻石的所有者或多签名的合约)才能进行钻石切割。

与传统代理类似,当钻石代理上有一个函数调用时,代理的 fallback 函数(回退函数)就会被触发。与钻石代理的主要区别是,在回退函数中,有一个 selectorToFacet 映射,存储并确定哪个逻辑合约地址有被调用的函数的实现。然后,它使用 delegatecall 来执行该函数,就像传统的代理一样。

Fallback 函数的实现

所有的代理都使用 fallback() 函数,将函数调用委托给外部地址。下面是钻石代理的实现和传统代理的实现。

值得注意的是它们的汇编代码块非常相似,因此唯一的区别是钻石代理委托调用中的切面地址和传统代理委托调用中的 impl 地址。

而其主要区别在于:在钻石代理中,切面的地址是由调用者的 msg.sig(函数选择器)到切面的地址的 hashmap 决定的,而在传统代理中,impl 地址不依赖于调用者的输入。

钻石代理 fallback 函数
钻石代理 fallback 函数
传统的代理 fallback 函数
传统的代理 fallback 函数

添加、替换和删除切面

SelectorToFacet 映射决定了哪个合约包含了每个函数选择器的实现。项目工作人员经常需要添加、替换或删除这种函数选择器到实现合约的映射。EIP-2535 规定:为了达到此目的,必须有一个 diamondCut() 函数。下面是一个示例接口。

每个 FacetCut 结构都包含一个切面地址和四字节的功能选择器数组,以在钻石代理合约中进行更新。FaceCutAction 允许人们添加、替换和删除功能选择器。diamondCut() 函数的实现应该包括足够的访问控制,防止存储槽的碰撞,在失败时进行恢复等。

查询切面

为了查询一个钻石代理有哪些功能,使用哪些切面,我们使用了「钻石放大镜」。「钻石放大镜」是一个特殊的切面,它实现了 EIP-2535 中定义的以下接口:

facets() 函数应该返回所有切面的地址和它们的四字节的函数选择器。facetFunctionSelectors() 函数应该返回一个特定切面所支持的所有函数选择器。facetAddresses() 函数应该返回一个钻石所使用的所有切面地址。

facetAddress() 函数应该返回支持给定选择器的切面,如果没有找到,则返回 address(0)。请注意,不应该有一个以上的切面地址具有相同的功能选择器。

存储槽管理

鉴于钻石代理将不同的函数调用委托给不同的实现合约,正确管理存储槽以防止冲突是至关重要的。EIP-2535 提到了几种存储槽管理方法。

钻石存储槽

这个切面可以在结构中声明状态变量。这个切面可以使用任意数量的结构,每个结构有不同的存储位置。每个结构在合约存储中都有一个特定的位置。切面可以声明他们自己的状态变量,但不能与其他切面所声明的状态变量的存储位置相冲突。EIP-2535 中提供了一个样本库和钻石存储合约,如下图所示:

App 存储

App 存储是钻石存储的一个更专精的版本。这种模式被用来更方便、更容易地共享切面的状态变量。一个 App 存储结构被定义为包含一个应用程序所需的任意数量和类型的状态变量。一个切面总是将 AppStorage 结构声明为第一个也是唯一一个状态变量,位于存储槽的第 0 位。然后不同的切面可以从该结构中访问变量。

其他

此外也有其他的存储槽管理策略,包括钻石存储和 AppStorage 的混合。比如有些结构在不同的切面之间共享,有些则是特定切面所特有的。在所有情况下,防止意外的存储槽碰撞是非常重要的。

 

与透明代理和 UUPS 代理的比较

目前 Web3 开发者社区使用的两种主要代理模式是透明代理模式和 UUPS 代理模式。在这一节中,我们简要比较了钻石代理模式与透明代理和 UUPS 代理模式。

1.EPI-2535:https://eips.ethereum.org/EIPS/eip-2535#Facets,%20State%20Variables%20and%20Diamond%20Storage

2.EPI-1967:https://eips.ethereum.org/EIPS/eip-1967   

3.Diamond proxy reference implementation: https://github.com/mudgen/Diamond   

4.OpenZeppelin implementation: https://github.com/OpenZeppelin/openzeppelin-contracts/tree/v4.7.0/contracts/proxy

钻石代理合约安全最佳实践

代理和可升级的解决方案是较复杂的系统,OpenZeppelin 为 UUPS、透明及 Beacon 可升级代理提供代码库及全面的文档。然而对于钻石代理模式,虽然 OpenZeppelin 肯定了它的好处,但他们仍然决定不把 EIP-2535 钻石的实现纳入他们的库中。

 因此,使用现有的第三方库或自行实现该解决方案的开发者在实施时必须格外谨慎。在此我们编写了一份安全最佳实践清单,供开发者社区参考。

将合约逻辑分解成独立的切面以实现模块化设计和升级

通过将合约逻辑分解成更小、更容易管理的模块,开发人员可以更容易地测试和审计他们的代码。

此外,这种方法允许开发人员专注于构建和维护合约的特定方面,而不是管理一个复杂的、单一的代码库。最终的结果是一个更加灵活和模块化的代码库,可以很容易地在不影响合约其他部分的情况下被更新和修改。

资料来源:Aavegotchi Github
资料来源:Aavegotchi Github

在部署过程中,代理合约必须用有效的 DiamondCut facet 合约地址进行初始化

当钻石代理合约被部署时,它必须将 DiamondCutFacet 合约的地址添加到钻石代理合约中,并实现 diamondCut() 函数。diamondCut() 函数用于添加、删除或替换切面和函数,没有 DiamondCutFacet 和 diamondCut(),钻石代理无法正常工作。

资料来源:Mugen’s Diamond-3-Hardhat
资料来源:Mugen’s Diamond-3-Hardhat

请将存储结构中新状态变量添加到结构的末尾

在智能合约中向存储结构添加新的状态变量时,必须将其添加到结构的末端。在结构的开头或中间添加新的状态变量会导致新的状态变量覆盖现有的状态变量数据,而新的状态变量之后的任何状态变量都可能会引用错误的存储位置。

使用 AppStorage 模式时,不要在结构之外声明和使用状态变量 

AppStorage 模式要求为钻石代理声明一个且仅有一个结构,并且该结构为所有切面所共享。如果需要多个结构,应该使用 DiamondStorage 模式。

 不要将结构直接放在另一个结构中

不要将结构直接放在另一个结构中,除非确定不打算向内部结构添加更多状态变量。如果不覆盖结构之后所声明的变量存储槽,就无法在升级中向内部结构添加新的状态变量。 

解决方法是将新的状态变量添加到存储映射结构中,而不是直接将「结构」放置在「结构」中。映射中的变量存储槽计算方式不同,在存储中不连续。

不要向数组中使用的结构添加新状态变量

数组的大小将受到结构大小的影响。当一个新的状态变量被添加到一个结构中时,它会改变该结构的大小和布局。

如果该结构被用作数组中的一个元素,这可能会导致问题。如果结构的大小和布局发生变化,那么数组的大小和布局也会发生变化,这可能会导致索引或其他依赖结构大小和布局一致的操作出现问题。

 不要为不同结构使用相同的存储槽

 与其他代理模式类似,每个变量都应该有一个唯一的存储槽。否则,同一位置的两个不同结构会相互覆盖。

 

不要让 initialize() 函数不受保护

initialize() 函数通常用于设置重要的变量,如特权角色的地址。如果在合约部署时没有初始化,恶意行为者可以调用并控制合约。

建议在初始化 / 设置函数上加入适当的访问控制,或者确保该函数在合约部署时被调用,不能被再次调用。

不允许任何切面能够调用 selfdestruct()

如果合约中的任何一个切面能够调用 selfdestruct() 函数,它就有可能破坏整个合约,导致资金或数据损失。这在钻石代理模式中极其危险,因为多个切面可以访问代理合约的存储和数据。

总结

目前,我们看到越来越多的项目在他们的智能合约中采用钻石代理模式。与传统代理相比,它具有灵活性和其他优势。

然而,额外的灵活性也可能意味着给攻击者提供了更广泛的攻击面。我们希望这篇文章对开发者社区了解钻石代理模式的机制及其安全考虑有所帮助。

同时,项目团队应该进行严格的测试和第三方审计,以减少与实施钻石代理合约有关的漏洞风险。

Subscribe to 0x00pluto
Receive the latest updates directly to your inbox.
Mint this entry as an NFT to add it to your collection.
Verification
This entry has been permanently stored onchain and signed by its creator.