作者为道议程开发团队核心成员谭粤飞

一 基础知识

本标准中的钻石就是指智能合约,这种合约使用代理合约(下面会详述)引入新增加的功能

钻石的每个面都代表一个代理合约(delegate contract)

每个钻石可拥有多个面(代理合约)

合约需要调用哪个代理合约就可以调用哪个代理合约

任何关于合约的变化用diamond event表示

二 钻石的种类

1.可升级钻石(Upgradeable Diamond):有diamondCut函数,用于替换,新增和删除函数。

2.已完工钻石(Finished Diamond):它是一种可升级钻石,但是却在可升级钻石的基础上去掉了diamondCut函数,因此不再可更新。

3.单切钻石(Single Cut Diamond):它在构造函数中使用diamondCut函数添加除diamondCut函数以外所有的其它函数。这种钻石也不可更新。

 

三 关键术语

1.放大镜(loupe):它也是钻石的一个面(代理合约),这个代理合约提供各种功能用来查看其它的代理合约及钻石的功能。

2.不可更新函数(immutable function):它是定义在钻石或代理合约中,不可更新或删除的函数。

 

四 工作原理

1.钻石通过delegatecall调用代理合约中的函数。

2.钻石通过调用diamondCut函数增加、替换或删除代理合约以及函数。

3.当钻石发生任何变化时,产生一个DiamondCut事件。

 

五 使用钻石标准时的注意事项

1.包含fallback函数和构造函数,可以包含或不包含immutable function。

2.diamondCut函数可以在钻石中定义,定义为immutable function,也可以在代理合约中定义。

3.diamondCut函数将函数选择器(function selector)和代理合约挂钩并发送DiamondCut事件。

4.当钻石的某个函数被调用时,如果该函数是直接定义在钻石内的immutable function则马上执行;否则执行fallback函数,由fallback函数找到定义这个函数的代理合约,再由delegatecall执行这个函数;如果fallback函数找不到任何一个代理合约定义了这个函数,则执行过程回滚。

5.调用diamondCut函数时,用参数 bytes[] memory _diamondCut 来定义要添加、更新或删除的函数。这个过程中如果函数选择器(function selector)对应的代理合约地址为address(0),则从钻石中删除该函数选择器;否则将该函数选择器添加到钻石中,或用该函数选择器更新钻石中原有的函数选择器。如果函数选择器已经关联了指定的代理合约 或 该函数选择器不能被删除,则执行过程回滚。

6.每当有函数添加、更新或删除时,要发送DiamondCut事件,并记录之。

7.钻石必须遵循并实现ERC-165。如果钻石有diamondCut函数,则它的接口ID为 ”Diamond.diamondCut.selector“;DiamondLoupe的接口ID为 “DiamondLoupe.facets.selector ^ DiamondLoupe.facetFunctionSelectors.selector ^ DiamondLoupe.facetAddresses.selector ^ DiamondLoupe.facetAddress.selector”。

8.使用本标准时,亦可添加、更新或删除其它函数,但只要有任何改动,都要发送DiamondCut事件。

9.用户和钻石交互时,是和钻石的合约地址交互,钻石的合约地址是不变的,但钻石的代理合约地址可变(使用diamondCut函数改变)。

 

六 设计思路

1 关于函数选择器的使用

在本标准中,带有合约ABI的函数选择器能提供足够多关于一个函数的信息,方便用户的调用。虽说函数签名也包含函数的相关信息,但它不含返回值的信息,也无法提供函数是否只读的信息。此外,标准使用函数选择器也是因为它们在使用中更省燃料。

 

2 关于燃料费的考虑

当系统使用代理函数调用时,确实会产生额外的开销,但可以通过下面几种方式减小这种开销:

1)代理合约要尽量小,以减小燃料开销。函数越多自然消耗的燃料就越多。

2)钻石的大小没有上限,因此可以在其中加入优化燃料开销的函数。比如当设计者在钻石中实现ERC721标准时,当要进行批量转账时可以用ERC1412标准的批量转账函数以减小燃料开销。

3)因为代理合约可以尽量小,所以设计者在设计时可以用Solidity优化器产生尽量多的字节码但同时让代理合约使用尽量少的燃料。

 

3 关于合约存储的考虑

本标准并未定义数据的存储方式,但对此我们有如下建议:

关于钻石的存储:自Solidity 0.6.4开始,设计者可以在合约中的任何位置定义指向结构体的指针。这使得钻石和代理合约能分别创建各自的数据存储,避免互相干扰。更多细节可参考:New Storage Layout For Proxy Contracts and Diamonds.

 

继承的数据存储:由于钻石和它所有的代理合约都使用相同的存储空间,因此在钻石和代理合约的源代码中,相同的存储变量必须以相同的次序进行定义。这里提供一个具体设计的参考:

1)所有的存储变量都应该定义为internal。

2)创建一个存储合约,它包含钻石需要用到的存储变量。

3)让钻石继承该存储合约。

4)让代理合约继承该存储合约。

5)如果想增加一个新的代理合约,该合约用于增加新的存储变量,那就干脆创建一个新的存储合约并让其继承原有的存储合约,同时在这个新的存储合约中添加这些存储变量,使用这个新的存储合约和新的代理合约。

6)但凡想增加新的存储变量就重复第5步。

 

非结构化的存储:在某些情形下,设计者会使用汇编语言在某些特定的存储位置存储和读取数据。这个方法的优点是对先前使用过的存储位置,即便代理合约没有用过也不需要定义它们。

外部数据的存储可以用通用的API根据其数据类型进行存储,细节请参看ERC930。

 

不可更新的钻石(Immutable Diamond):

设计者可以设计一个不可更新的钻石----通过调用diamondCut函数删除diamondCut函数即可。删除此函数后,钻石将不再可进行任何改变。

 

函数的版本:

当用户调用某个函数时,可以通过函数所在的代理合约的地址判断该函数的版本。DiamondLoop接口中有个专门的函数facetAddress就是用于此目的。这个函数以函数选择器为参数,返回函数所在的代理合约的地址。

 

代理合约之间共享函数:

当我们需要调用另外一个代理合约中的函数时,可以采取下列方式:

1)将函数代码拷贝进本代理合约中。

2)将多个代理合约都有的函数放到另一个合约中,并让这些代理合约继承该合约。

3)用delegatecall调用其它代理合约中的函数,如下所示:

bytes memory myFunction = abi.encodeWithSignature("myFunction(uint256)", 4);

(bool success, uint result) = address(this).delegatecall(myFunction);

require(success, "myFunction failed.");

 

4 关于安全的考虑

所有权和认证

注意:关于钻石所有权和认证的设计与实现并不是本标准要讨论的内容,这里的示例仅供参考。

对钻石所有权和认证的设计可以有多种方式。其中对认证的设计可以简单可以复杂。钻石标准本身没有限制。比如对所有权和认证的处理可以就简单地用一个账户地址处理,该账户地址可以添加、更新或删除任何函数,也可以用DAO来处理。

 

钻石存储的安全

如果一个用户添加或更新了函数,不管他喜欢还是不喜欢,他实际上就改变了存储。这一方面很强大,另一方面也很危险。不过这也可以用来减小或规避风险,比如可以限制“谁”才能添加、更新、删除函数,限制“什么时候”才能添加、更新、删除函数。

对于限制“谁”,我们可以这么做:

1)只允许某个受信的用户或组织更新钻石

2)只允许某个DAO更新钻石

3)只允许多签地址更新钻石

4)只允许该钻石的所有者更新

5)把钻石定义为single cut diamond,不让任何人更新

对于限制“什么时候”,我们可以这么做:

1)只在系统的开发和测试阶段允许更新。当系统上线主网时,定义为single cut diamond

2)当功能定型后,去掉更新的功能

3)在diamondCut中编写进时间限制,限制其更新的时间。

参考链接

https://eips.ethereum.org/EIPS/eip-2535

EIP-2535即钻石标准介绍

Log in to comment