文章详细介绍了ERC-2535钻石模式(Diamond Pattern),这是一种代理模式,代理合约可以同时使用多个实现合约。文章深入讨论了钻石模式的实现原理、优势、以及与透明可升级代理(Transparent Upgradeable Proxy)和UUPS的区别。还介绍了钻石模式的应用场景和最佳实践,包括如何实现不可变钻石和可升级钻石,并提供了相关代码示例。
钻石模式 (ERC-2535) 是一种 代理模式,其中代理合约同时使用多个实现合约,这与 透明可升级代理 和 UUPS 不同,后者在任何时刻仅依赖单个实现合约。代理合约根据其接收到的 calldata 的 函数选择器 来确定调用哪个实现合约进行 delegatecall(具体机制稍后描述):
拥有多个实现合约的优点之一是代理合约可以使用的逻辑数量没有实际的上限。回想一下,EVM 对智能合约字节码的大小限制为 24kb。如果开发者需要部署多达 48kb 的字节码,一种可行的解决方案是使用 fallback-extension 模式。对于超过 48kb 的字节码,钻石模式是最常见的解决方案。
在钻石模式的命名法中,“代理合约”被称为钻石,而“实现合约”被称为“面”。这两个术语指代相同的东西,导致了一些困惑,所以我们要强调一点:
通过更改一个或多个实现合约(面),可以升级钻石(代理)。或者,钻石可以是不可升级的(不可变或 immutable),通过不支持改变面(实现合约)的机制来实现。
由于同时处理多个实现合约所产生的复杂性,钻石代理享有“专家设计模式”的声誉。事实上,钻石模式在 EVM 开发者中有些争议,因为它被认为是复杂的(我们在此不参与辩论或进行复杂性判断)。
规范本身相对较小。它需要四个公共的视图函数 — 但如果钻石是可升级的,则需要一个用于切换实现合约的第五个状态变更函数。钻石模式仅需强制一个事件(无论合约是否可升级)。尽管规范很小,实现 这四个(或五个)函数比其他代理模式复杂得多。
但是,若具备正确的先决条件(要求相对较高!),钻石模式并不特别难以构思。我们假设读者对我们 代理模式书 的前十三章所涵盖的主题很熟悉。如果你没有阅读这些章节或者对这些主题不熟悉,学习钻石模式将会十分艰难,因此确保已经具备相关的先决条件。
以下是该模式需要解决的一些问题:
在本文中,我们将展示如何解决上述所有问题。请注意,ERC-2535 不要求钻石代理是可升级的。代理可以具有硬编码的实现合约,但仍然是有效的钻石。
为了在开始时保持简单,我们首先展示不可变钻石。
不可变钻石,也叫静态钻石或单切钻石,是一个代理合约,具有多个实现合约 — 并且没有实现合约可以被升级(可升级钻石通过移除升级功能可以变为不可变)。
与 UUPS 和透明可升级代理所使用的 OpenZeppelin Proxy 相比,钻石代理的 代理 部分的代码应该是熟悉的:
// 查找被调用 функции 的面,并执行该
// 函数如果找到面并返回任何值。
fallback() external payable {
// 从函数选择器获取面
address facet = facetAddress(msg.sig);
require(facet != address(0));
// 以下代码与 OpenZeppelin Proxy.sol 相同
// 从面执行外部函数,并返回任何值。
assembly {
// 复制函数选择器和任何参数
calldatacopy(0, 0, calldatasize())
// 使用该面执行函数调用
let result := delegatecall(gas(), facet, 0, calldatasize(), 0, 0)
// 获取返回值
returndatacopy(0, 0, returndatasize())
// 将返回值或错误返回给调用者
switch result
case 0 {revert(0, returndatasize())}
default {return (0, returndatasize())}
}
}
与传统代理的唯一区别是两行代码:
address facet = facetAddress(msg.sig);
require(facet != address(0));
上述代码通过 msg.sig
获取交易的前四个字节(函数选择器),然后使用 facetAddress()
来确定实现该函数的面的地址(注意 facetAddress()
可以是代理逻辑的一部分,也可以在其他一个面中存在并通过 delegatecall 调用 — 这将在后面讨论)。代理随后与其接收到的相同 calldata 一起调用该地址。
EIP-2535 并未指定如何将函数选择器映射到实现合约的地址。对于静态钻石,一个合理的解决方案是硬编码关系。这将是我们下一节采用的方法。
对于可升级钻石,显然硬编码选择器不可行,我们必须依靠映射,如我们在相关章节将看到的那样。
下面展示一个具有两个实现合约(面)的代理合约:
Add
暴露一个公共函数 add()
,返回其参数的和。Multiply
暴露两个公共函数 multiply()
和 exponent()
,其功能与名称所示相同。下面的代码展示了一个使用两个实现合约的单个代理。在 Diamond
中的 facetAddress()
函数接受 msg.sig
并返回实现该函数签名的面的地址(如果有的话)。下面的代码尚未符合钻石标准,但我们将使其逐步符合标准。
// 第一个实现合约
contract Add {
// 选择器: 0x771602f7
function add(uint256 x, uint256 y) external pure returns (uint256 z) {
z = x + y;
}
}
// 第二个实现合约
contract Multiply {
// 选择器: 0x165c4a16
function multiply(uint256 x, uint256 y) external pure returns (uint256 z) {
z = x * y;
}
// 选择器: 0x2f8cd8b1
function exponent(uint256 x, uint256 y) external pure returns (uint256 z) {
z = x ** y;
}
}
// 代理合约
contract Diamond {
address immutable ADD_ADDR;
address immutable MULTIPLY_ADDR;
constructor() {
ADD_ADDR = address(new Add());
MULTIPLY_ADDR = address(new Multiply());
}
function facetAddress(bytes4 selector) public view returns (address) {
if (selector == 0x771602f7) return ADD_ADDR;
else if (selector == 0x165c4a16) return MULTIPLY_ADDR;
else if (selector == 0x2f8cd8b1) return MULTIPLY_ADDR;
else return address(0);
}
fallback() external payable {
address facet = facetAddress(msg.sig);
require(facet != address(0), "函数不存在");
assembly {
calldatacopy(0, 0, calldatasize())
let result := delegatecall(gas(), facet, 0, calldatasize(), 0, 0)
returndatacopy(0, 0, returndatasize())
switch result
case 0 { revert(0, returndatasize()) }
default { return(0, returndatasize()) }
}
}
}
EIP-2535 标准并未指定调用不存在的函数时的错误消息。在我们的例子中,我们回滚时使用字符串 "函数不存在"
,但自定义错误会更 节省 gas。
为了简化,我们的钻石在 构造函数 中部署了两个面,但在实际操作中并不是这样。实际上,面是分别部署的,代理在某种方式(例如通过构造函数参数或单独的函数)中被“告知”它们。
某些钻石将面实现为具有外部函数的库,而不是合约,但 EIP-2535 并不要求面是库或合约。
为了简单起见,我们使用一系列的 else-if 语句来将函数选择器与面地址进行匹配,但如果选项很多,这并不好节省 gas。对于静态钻石,提前对选择器进行排序,然后使用二分查找会更高效 — 但我们将在后面讨论此内容。
当转账以太时,将不会有 calldata。在这种情况下,msg.sig
将返回 0x00000000
,并且这不会映射到一个面地址。如果合约不打算接收以太,这没关系。不过,如果合约预期会接收以太,或期望会对此做出反应,那么 0x00000000
应该映射到一个具有相关逻辑的函数,或者至少是一个不会回滚的函数。要记住,攻击者可以通过发送没有 calldata 或发送 0x00000000
作为 calldata 触发此函数,因此逻辑需要优雅地处理这两种情况。
为了使我们的合约符合 EIP-2535 的要求,我们必须实现四个强制性的公共查看函数,每个函数如下所述。
请注意,facetAddress()
是公共的 — EIP-2535 要求钻石代理公开一个函数,带有以下签名:
function facetAddress(bytes4 selector) external view returns (address);
在这方面我们已经符合标准。
除了 facetAddress(bytes4 selector)
,EIP-2535 还强制要求一个名为 facetAddresses()
(复数) 的函数,该函数返回钻石使用的所有面 — 换句话说,地址列表。其签名如下:
function facetAddresses() public view returns (address[] memory addresses);
由于我们钻石的面不会改变,因此我们只需硬编码面地址的列表:
contract Diamond {
address immutable ADD_ADDR;
address immutable MULTIPLY_ADDR;
constructor() {
ADD_ADDR = address(new Add());
MULTIPLY_ADDR = address(new Multiply());
}
function facetAddress(bytes4 selector) public view returns (address) {
if (selector == 0x771602f7) return ADD_ADDR;
else if (selector == 0x165c4a16) return MULTIPLY_ADDR;
else if (selector == 0x2f8cd8b1) return MULTIPLY_ADDR;
else return address(0);
}
// ┌────────────────────┐
// │ │
// │ 这段代码是新代码 │
// │ │
// └────────────────────┘
function facetAddresses() public view returns (address[2] memory addresses) {
addresses[0] = ADD_ADDR;
addresses[1] = MULTIPLY_ADDR;
}
// --------
fallback() external payable {
address facet = facetAddress(msg.sig);
require(facet != address(0), "函数不存在");
assembly {
calldatacopy(0, 0, calldatasize())
let result := delegatecall(gas(), facet, 0, calldatasize(), 0, 0)
returndatacopy(0, 0, returndatasize())
switch result
case 0 { revert(0, returndatasize()) }
default { return(0, returndatasize()) }
}
}
}
返回的地址列表(面地址)没有强制要求顺序。
给定一个面地址作为参数,facetFunctionSelectors()
返回该面所有公共函数的选择器。其签名如下:
function facetFunctionSelectors(address _facet) external view returns (bytes4[] memory facetFunctionSelectors_);
我们可以这样实现它:
contract Diamond {
address immutable ADD_ADDR;
address immutable MULTIPLY_ADDR;
constructor() {
ADD_ADDR = address(new Add());
MULTIPLY_ADDR = address(new Multiply());
}
function facetAddress(bytes4 selector) public view returns (address) {
if (selector == 0x771602f7) return ADD_ADDR;
else if (selector == 0x165c4a16) return MULTIPLY_ADDR;
else if (selector == 0x2f8cd8b1) return MULTIPLY_ADDR;
else return address(0);
}
function facetAddresses() public view returns (address[] memory addresses) {
addresses[0] = ADD_ADDR;
addresses[1] = MULTIPLY_ADDR;
}
// ┌────────────────────┐
// │ │
// │ 这段代码是新代码 │
// │ │
// └────────────────────┘
function facetFunctionSelectors(address _facet) public view returns (bytes4[] memory) {
if (_facet == ADD_ADDR) {
bytes4[] memory facetFunctionSelectors_ = new bytes4[](1);
facetFunctionSelectors_[0] = 0x771602f7;
return facetFunctionSelectors_;
}
else if (_facet == MULTIPLY_ADDR) {
bytes4[] memory facetFunctionSelectors_ = new bytes4[](2);
facetFunctionSelectors_[0] = 0x165c4a16;
facetFunctionSelectors_[1] = 0x2f8cd8b1;
return facetFunctionSelectors_;
}
else {
bytes4[] memory facetFunctionSelectors_ = new bytes4[](0);
return facetFunctionSelectors_;
}
}
// --------
fallback() external payable {
address facet = facetAddress(msg.sig);
require(facet != address(0), "函数不存在");
assembly {
calldatacopy(0, 0, calldatasize())
let result := delegatecall(gas(), facet, 0, calldatasize(), 0, 0)
returndatacopy(0, 0, returndatasize())
switch result
case 0 { revert(0, returndatasize()) }
default { return(0, returndatasize()) }
}
}
}
最后,EIP-2535 强制要求一个函数 facets()
,该函数不接受参数并返回一个结构体列表,每个结构体包含一个面地址和该面所有函数选择器的列表。
其签名如下:
struct Facet {
address facetAddress;
bytes4[] functionSelectors;
}
function facets() external view returns (Facet[] memory facets_);
facets()
返回的信息可以通过
facetAddresses()
获取所有面地址
Facet
结构的字段 facetAddress
facetFunctionSelectors()
并将函数选择器列表放入结构的 functionSelectors
字段。另一种选择是直接在函数中硬编码答案,这在某些情况下可能更高效(对于可升级的钻石,硬编码显然不可行)。在我们的钻石中,我们将使用循环方法实现 facets()
,如下所示。在本代码块的底部查看新代码:
contract Add {
// 选择器: 0x771602f7
function add(uint256 x, uint256 y) external pure returns (uint256 z) {
z = x + y;
}
}
contract Multiply {
// 选择器: 0x165c4a16
function multiply(uint256 x, uint256 y) external pure returns (uint256 z) {
z = x * y;
}
// 选择器: 0x2f8cd8b1
function exponent(uint256 x, uint256 y) external pure returns (uint256 z) {
z = x ** y;
}
}
contract Diamond {
address immutable ADD_ADDR;
address immutable MULTIPLY_ADDR;
constructor() {
ADD_ADDR = address(new Add());
MULTIPLY_ADDR = address(new Multiply());
}
function facetAddress(bytes4 selector) public view returns (address) {
if (selector == 0x771602f7) return ADD_ADDR;
else if (selector == 0x165c4a16) return MULTIPLY_ADDR;
else if (selector == 0x2f8cd8b1) return MULTIPLY_ADDR;
else return address(0);
}
function facetAddresses() public view returns (address[] memory) {
address[] memory addresses = new address[](2);
addresses[0] = ADD_ADDR;
addresses[1] = MULTIPLY_ADDR;
return addresses;
}
function facetFunctionSelectors(address _facet) public view returns (bytes4[] memory) {
if (_facet == ADD_ADDR) {
bytes4[] memory selectors = new bytes4[](1);
selectors[0] = 0x771602f7;
return selectors;
}
else if (_facet == MULTIPLY_ADDR) {
bytes4[] memory selectors = new bytes4[](2);
selectors[0] = 0x165c4a16;
selectors[1] = 0x2f8cd8b1;
return selectors;
}
// 对于未知的面返回空数组
return new bytes4[](0);
}
// ┌────────────────────┐
// │ │
// │ 这段代码是新代码 │
// │ │
// └────────────────────┘
struct Facet {
address facetAddress;
bytes4[] functionSelectors;
}
function facets() public view returns (Facet[] memory) {
address[] memory fa = facetAddresses();
Facet[] memory _facets = new Facet[](2);
for (uint256 i = 0; i < fa.length; i++) {
_facets[i].facetAddress = fa[i];
_facets[i].functionSelectors = facetFunctionSelectors(fa[i]);
}
return _facets;
}
// --------
fallback() external payable {
address facet = facetAddress(msg.sig);
require(facet != address(0), "函数不存在");
assembly {
calldatacopy(0, 0, calldatasize())
let result := delegatecall(gas(), facet, 0, calldatasize(), 0, 0)
returndatacopy(0, 0, returndatasize())
switch result
case 0 { revert(0, returndatasize()) }
default { return(0, returndatasize()) }
}
}
}
实施这四个公共函数后,我们现在已经实现了钻石代理的所有强制外部函数。
整体来看,这四个函数的定义在名为 IDiamondLoupe
的接口中。所有钻石都必须实现 IDiamondLoupe
。可以记住,“loupe” 是一个小放大镜,用来查看(“查看”)钻石,而这几个函数都是视图函数:
interface IDiamondLoupe {
struct Facet {
address facetAddress;
bytes4[] functionSelectors;
}
/// @notice 获取所有面地址及其四字节函数选择器。
/// @return facets_ Facet
function facets() external view returns (Facet[] memory facets_);
/// @notice 获取特定面支持的所有函数选择器。
/// @param _facet 面地址。
/// @return facetFunctionSelectors_
function facetFunctionSelectors(address _facet) external view returns (bytes4[] memory facetFunctionSelectors_);
/// @notice 获取钻石使用的所有面地址。
/// @return facetAddresses_
function facetAddresses() external view returns (address[] memory facetAddresses_);
/// @notice 获取支持给定选择器的面。
/// @dev 如果未找到面则返回 address(0)。
/// @param _functionSelector 函数选择器。
/// @return facetAddress_ 面地址。
function facetAddress(bytes4 _functionSelector) external view returns (address facetAddress_);
}
我们离完全合规的钻石代理还差一步:记录对钻石所做的更改。这是我们接下来要讨论的主题。
我们正在运行的钻石示例不可升级。实际上,钻石是可升级的,可以更改它们的面及其关联的函数选择器。
对面所做的任何更改都必须记录 — 即使是对不可升级(静态)钻石,变化(添加面和函数选择器)也发生在部署时,因此需要记录。
记录面选择器的理论是,应有两种方法来确定钻石支持的函数选择器:
甚至像我们这样硬编码的面也必须被记录,因此必须发出事件。在我们的示例中,发射必须在部署期间发生。这些日志是标准所需的。
当我们添加一个面(或进行任何更改,例如替换或移除)时,这个动作被称为 钻石切割。
钻石切割并不意味着移除面,正如“切割”可能暗示的那样。它是指以某种方式更改钻石。对面进行的任何更改都需要发出 DiamondCut
事件,在接下来的代码块中定义。(记住这种“切割”的命名法是因为在实际钻石中,当实物宝石被“切割”时,会出现新的一面 — 或称作 “面” — 在切割处)。
DiamondCut
事件定义在名为 IDiamond
的新接口中。每当添加、替换或移除某个面的一项函数时,必须发出事件。DiamondCut
事件的定义如下所示:
interface IDiamond {
enum FacetCutAction {Add, Replace, Remove}
// Add=0, Replace=1, Remove=2
struct FacetCut {
address facetAddress;
FacetCutAction action;
bytes4[] functionSelectors;
}
event DiamondCut(FacetCut[] _diamondCut, address _init, bytes _calldata);
}
由于 FacetCut[]
是一个列表,因此可以一次更改多个面。
与其他代理模式(如 透明可升级代理 或 [UUPS 代理](https://learnblockchain.cn/article/9221)不同,钻石没有通过更改面地址来升级的机制。仅当所有关联的函数选择器被移除时,面的 (实现合约) 才会被移除。 当用新实现地址添加函数选择器时,面也会被隐式添加。
_init
和 _calldata
参数的作用与 OpenZeppelin 初始化器 相同 — 我们稍后会再讨论。如果不需要初始化数据,则 _init
应为 address(0)
,_calldata
应 ""
或为空字节。
让我们更新我们的钻石,以便在构造函数中发出此事件:
contract Add {
// 选择器: 0x771602f7
function add(uint256 x, uint256 y) external pure returns (uint256 z) {
z = x + y;
}
}
contract Multiply {
// 选择器: 0x165c4a16
function multiply(uint256 x, uint256 y) external pure returns (uint256 z) {
z = x * y;
}
// 选择器: 0x2f8cd8b1
function exponent(uint256 x, uint256 y) external pure returns (uint256 z) {
z = x ** y;
}
}
interface IDiamond {
enum FacetCutAction {Add, Replace, Remove}
// Add=0, Replace=1, Remove=2
struct FacetCut {
address facetAddress;
FacetCutAction action;
bytes4[] functionSelectors;
}
event DiamondCut(FacetCut[] _diamondCut, address _init, bytes _calldata);
}
contract Diamond is IDiamond {
address immutable ADD_ADDR;
address immutable MULTIPLY_ADDR;
constructor() {
ADD_ADDR = address(new Add());
MULTIPLY_ADDR = address(new Multiply());
// ┌────────────────────┐
// │ │
// │ 这段代码是新代码 │
// │ │
// └────────────────────┘
// 总共有 3 个面:
// [add, [add]]
// [multiply, [multiply, exponent]]
// [this, [facets, facetAddress, facetAddresses, facetFunctionSelectors]]
FacetCut[] memory _diamondCuts = new FacetCut[](3);
enum FacetCutAction {Add, Replace, Remove}
_diamondCuts[0].facetAddress = ADD_ADDR;
bytes4[] memory _addFacets = new bytes4[](1);
_addFacets[0] = 0x771602f7;
// 添加到 _diamondCuts
_diamondCuts[0].action = Add;
_diamondCuts[0].functionSelectors = _addFacets;
_diamondCuts[1].facetAddress = MULTIPLY_ADDR;
bytes4[] memory _mulFacets = new bytes4[](2);
_mulFacets[0] = 0x165c4a16;
_mulFacets[1] = 0x2f8cd8b1;
// 添加到 _diamondCuts
_diamondCuts[1].action = Add;
_diamondCuts[1].functionSelectors = _mulFacets;
// 请注意,IDiamondLoupe 接口函数也被记录。
_diamondCuts[2].facetAddress = address(this);
bytes4[] memory _loupeFacets = new bytes4[](4);
_loupeFacets[0] = this.facetAddress.selector;
_loupeFacets[1] = this.facetAddresses.selector;
_loupeFacets[2] = this.facets.selector;
_loupeFacets[3] = this.facetFunctionSelectors.selector;
// 添加到 _diamondCuts
_diamondCuts[2].action = Add;
_diamondCuts[2].functionSelectors = _loupeFacets;
emit DiamondCut(_diamondCuts, address(0), "");
// --------
}
function facetAddress(bytes4 selector) public view returns (address) {
if (selector == 0x771602f7) return ADD_ADDR;
else if (selector == 0x165c4a16) return MULTIPLY_ADDR;
else if (selector == 0x2f8cd8b1) return MULTIPLY_ADDR;
else return address(0);
}
function facetAddresses() public view returns (address[] memory) {
address[] memory addresses = new address[](2);
addresses[0] = ADD_ADDR;
addresses[1] = MULTIPLY_ADDR;
return addresses;
}
function facetFunctionSelectors(address _facet) public view returns (bytes4[] memory) {
if (_facet == ADD_ADDR) {
bytes4[] memory selectors = new bytes4[](1);
selectors[0] = 0x771602f7;
return selectors;
}
else if (_facet == MULTIPLY_ADDR) {
bytes4[] memory selectors = new bytes4[](2);
selectors[0] = 0x165c4a16;
selectors[1] = 0x2f8cd8b1;
return selectors;
}
// 对于未知的面返回空数组
return new bytes4[](0);
}
struct Facet {
address facetAddress;
bytes4[] functionSelectors;
}
function facets() public view returns (Facet[] memory) {
address[] memory fa = facetAddresses();
Facet[] memory _facets = new Facet[](2);
for (uint256 i = 0; i < fa.length; i++) {
_facets[i].facetAddress = fa[i];
_facets[i].functionSelectors = facetFunctionSelectors(fa[i]);
}
return _facets;
}
fallback() external payable {
address facet = facetAddress(msg.sig);
require(facet != address(0), "函数不存在");
assembly {
calldatacopy(0, 0, calldatasize())
let result := delegatecall(gas(), facet, 0, calldatasize(), 0, 0)
returndatacopy(0, 0, returndatasize())
switch result
case 0 { revert(0, returndatasize()) }
default { return(0, returndatasize()) }
}
}
receive() external payable {}
}
我们现在拥有一个完全符合 EIP-2535 的钻石代理。通常,IDiamondLoupe
函数存储在单独的面中,但为了简单起见,我们将它们放在钻石中,以保持简单。
无论 IDiamondLoupe
的函数存储在钻石本身还是另一个面中,标准都要求记录它们的地址、发生的操作(添加一个面)和函数选择器。
该 EIP 的一个争议方面是,函数选择器可以通过解析过去的日志和通过调用 IDiamondLoupe
中的视图函数两种方式确定。这为完成相同的事情带来了重复的逻辑。
通过公共函数公开同样的数据的理由是这使得与区块浏览器等外部系统的集成更简单。此外,升级脚本可以原子检查函数选择器是否已经存在,然后再注册一个新的。DiamondCut
事件的意图是展示升级的历史。
在 ERC-1967 中,区块浏览器可以查询存储插槽并立即识别逻辑合约 — 区块浏览器无需解析 ERC-1967 发出的日志,这些日志包含相同的信息。
EIP-2535 标准建议添加一个如下所示的 diamondCut()
函数,以便可以添加、更改或移除面。
interface IDiamondCut is IDiamond {
/// @notice 添加/替换/移除任意数量的函数,并可选择性地执行
/// 通过 delegatecall 调用的函数
/// @param _diamondCut 包含面地址和函数选择器的数组
/// @param _init 要执行 `_calldata` 的合约或面的地址
/// @param _calldata 一个函数调用,包括函数选择器和参数
/// `_calldata` 通过 delegatecall 在 `_init` 上执行
function diamondCut(
FacetCut[] calldata _diamondCut,
address _init,
bytes calldata _calldata
) external;
}
标准并不 要求 升级函数为 diamondCut()
或实现上述签名。开发者可以使用自己的函数,如 changeTheFacets()
,但是该函数必须根据所做的面更新类型发出 DiamondCut
事件。
要存储函数选择器和面的信息,至少我们希望建立一个映射,从函数选择器到面的地址:
mapping(bytes4 => address) facetToSelectors;
这个数据结构使我们能够:
msg.sig
找到应该进行 delegatecall 的实现合约facetToSelectors[selector] == address(0)
作为回顾,这里是我们需要支持的 IDiamondLoupe
中的视图函数:
facetAddress(bytes4 selector)
给定一个函数选择器,返回该面facetAddresses()
返回所有面地址facetFunctionSelectors(address facet)
给定一个地址,返回该面的所有函数选择器facets()
返回所有面地址及其函数选择器以下是我们对每一个函数的实现:
facetAddress(bytes4 selector)
函数可以直接用 facetToSelectors
映射的视图函数 — 或者我们可以将映射设为公共的。facetAddresses()
的所有地址,我们必须:
facetAddress(bytes4 selector)
,然后构建唯一地址的列表。facetFunctionSelectors(address facet)
返回一个面的所有函数选择器,我们必须:
mapping(address facet => bytes4[])
,该映射存储与每个映射相连的函数选择器列表facetAddress(bytes4 selector)
,对每个返回的面添加该地址,如果返回的 facet
是 facetFunctionSelectors
的参数中代表的 facet
。facets()
返回同样的信息,我们省略对其实现的进一步讨论。这之间存在着一种基本的权衡。如果我们使用更多的数据结构,将使链上调用视图函数更便宜,因为它们不需要“重建”这些函数返回的数据,但在升级期间需要更新的数据结构会更多。因此我们必须选择:
在链上调用任何 IDiamondLoupe
视图函数的情况极其罕见,因为它们是为链下消费而设计的。因此,选择更少的数据结构是更可取的。
如前所述,我们需要至少一个映射来存储选择器和地址信息,例如:
mapping(bytes4 => address) facetToAddress;
在钻石中,但随后在第一个存储插槽分配给任何面时,这个映射可能与该映射碰撞。
在钻石代理中,存储碰撞比其他代理模式要复杂,因为碰撞不仅可能在相同实现合约的升级中发生,也可能在面之间发生。
钻石模式并未定义如何管理存储。一种处理碰撞的简单方法是使用存储命名空间。有关如何使用命名空间的详细说明,可以参见 EIP-7201 存储命名空间。作为回顾,在存储命名空间中,一个合约的状态变量被分组到一个结构中,而这个结构的基址存储在一个伪随机槽中,通常由一个字符串的哈希值决定。因此,每个合约都有自己的基础存储槽,使得存储碰撞的可能性极小。
EIP-7201 是从更早期的一个解决方案派生而来的,该解决方案称为“diamond storage”(钻石存储)。EIP-2535 还提出了另一种模式,称为“App Storage”(应用存储)。然而,EIP-2535 并未规定存储应如何管理,因此我们仅向读者推荐一个可行的解决方案,即使用 EIP-7201。感兴趣的读者可以直接从 EIP 中了解“diamond storage”和“app storage”的模式 — 这两者都得到了 EIP 作者的推荐。
如果我们选择保留操作钻石所需的最少存储,则代理的命名空间应该是一个具有以下字段的结构:
bytes4[] selectors
。每当我们添加一个选择器时,我们需要在此列表中扫描,以确保我们没有添加已存在的选择器。selectors
中的索引也是有帮助的。这样,当我们移除一个选择器时,我们可以快速查找它在数组中的索引。然后,我们将该条目与最后一个条目交换,并从列表中弹出。因此,我们不再存储 selector => address
,而是存储一个结构,保存地址和选择器在数组中的位置。因此,我们的映射保存 selector => (address, index_in_selectors)
。下面的代码实现了以上两点:
selectors
是选择器的列表FacetAddressAndSelectorPosition
存储 facetAddress 以及选择器在 selectors
中的索引struct FacetAddressAndSelectorPosition {
address facetAddress;
uint16 selectorPosition; // 选择器在 `selectors` 中的索引
}
struct DiamondStorage {
mapping(bytes4 => FacetAddressAndSelectorPosition) facetAddressAndSelectorPosition;
bytes4[] selectors;
}
该结构可以使用 EIP-7201 中相同的模式进行访问。
为了保持简单,存放上述信息的结构和设置存储指针到该结构的函数可以保存在一个名为 LibDiamond
的单独库中。该库提供了一个函数 diamondStorage()
,返回指向该结构的指针,以及 facetAddress(bytes4 selector)
。在这个库中定义 facetAddress
是可选的,仅为方便。
// ┌────────────────────┐
// │ │
// │ 存储的代码 │
// │ │
// └────────────────────┘
library LibDiamond {
// keccak256(abi.encode(uint256(keccak256("diamond.storage")) - 1)) & ~bytes32(uint256(0xff));
bytes32 constant DIAMOND_STORAGE_POSITION = 0xd7ce2c87e6a71bef91a0dfa43113050aa4eae7c1a7c451ae61d9077904d7cd00;
struct DiamondStorage {
mapping(bytes4 => FacetAddressAndSelectorPosition) facetAddressAndSelectorPosition;
bytes4[] selectors;
}
function diamondStorage()
internal
pure
returns (DiamondStorage storage ds) {
bytes32 position = DIAMOND_STORAGE_POSITION;
assembly {
// 更改存储指针的槽
ds.slot := position
}
}
function facetAddress(bytes4 _functionSelector)
external
override
view
returns (address facetAddress_) {
LibDiamond.DiamondStorage storage ds = LibDiamond.diamondStorage();
facetAddress_ = ds.facetAddressAndSelectorPosition[_functionSelector].facetAddress;
}
}
对于可升级的钻石,通常把 IDiamondLoupe
函数放在它们自己的 facet 中,而不是放在代理中。对这个合约没有命名的要求,但 DiamondLoupeFacet
是一个合理的描述。下面我们展示了 DiamondLoupeFacet
使用 LibDiamond
库实现了 IDiamondLoupe
中的一部分外部 facetAddress
函数。
import { LibDiamond } from "./libraries/LibDiamond.sol";
// ┌─────────────────────┐
// │ │
// │ DiamondLoupeFacet │
// │ │
// └─────────────────────┘
contract DiamondLoupeFacet is IDiamondLoupe {
/// @notice 获取支持给定选择器的 facet 地址。
/// @dev 如果找不到 facet,返回 address(0)。
/// @param _functionSelector 函数选择器。
/// @return facetAddress_ facet 地址。
function facetAddress(bytes4 _functionSelector)
external
override
view
returns (address facetAddress_) {
LibDiamond.DiamondStorage storage ds = LibDiamond.diamondStorage();
facetAddress_ = ds.facetAddressAndSelectorPosition[_functionSelector].facetAddress;
}
// 其他未显示的函数
}
Nick Mudge,EIP-2535 的作者维护了三个参考实现(diamond-1、diamond-2 和 diamond-3),位于以下仓库:
<https://github.com/mudgen/diamond>
这些实现优化了我们之前讨论的权衡:如果 IDiamondLoupe
中的视图函数在链上查询便宜,则更新将非常昂贵,反之亦然。
diamond-1
和 diamond-2
尽可能少地使用存储来跟踪 facets 和选择器,仅使用一个函数选择器列表以及从函数选择器到 facet 地址的映射。下面我们看到 diamond-1
的存储。
注意,参考实现将函数选择器的数组作为 uint256 => bytes32
选择器槽的映射进行存储,以此将 8 个函数选择器打包到一个槽中。映射在 gas 上比数组稍微高效,因为它们在查找之前不会隐式检查数组的长度。这个“数组”的长度会作为 selectorCount
单独存储。
struct DiamondStorage {
// 函数选择器 => facet 地址及选择器在选择器数组中的位置
mapping(bytes4 => FacetAddressAndSelectorPosition) facetAddressAndSelectorPosition;
bytes4[] selectors;
mapping(bytes4 => bool) supportedInterfaces;
// 合约的所有者
address contractOwner;
}
另一方面,diamond-3
显式存储 facet 地址和从 facet 地址到它存储的函数选择器列表的映射:
struct FacetAddressAndPosition {
address facetAddress;
uint96 functionSelectorPosition; // 在 facetFunctionSelectors.functionSelectors 数组中的位置
}
struct FacetFunctionSelectors {
bytes4[] functionSelectors;
uint256 facetAddressPosition; // facetAddress 在 facetAddresses 数组中的位置
}
struct DiamondStorage {
// 将函数选择器映射到 facet 地址和
// 选择器在 facetFunctionSelectors.selectors 数组中的位置
mapping(bytes4 => FacetAddressAndPosition) selectorToFacetAndPosition;
// 将 facet 地址映射到函数选择器
mapping(address => FacetFunctionSelectors) facetFunctionSelectors;
// facet 地址
address[] facetAddresses;
// 用于查询合约是否实现了一个接口。
// 用于实现 ERC-165。
mapping(bytes4 => bool) supportedInterfaces;
// 合约的所有者
address contractOwner;
}
diamond-3
的 DiamondLoupe
实现非常简单,因为它仅仅是那些存储变量的薄包装:
contract DiamondLoupeFacet is IDiamondLoupe, IERC165 {
// 钻石 Loupe 函数
////////////////////////////////////////////////////////////////////
/// 这些函数预计将被工具频繁调用。
//
// struct Facet {
// address facetAddress;
// bytes4[] functionSelectors;
// }
/// @notice 获取所有 facets 及其选择器。
/// @return facets_ Facet
function facets() external override view returns (Facet[] memory facets_) {
LibDiamond.DiamondStorage storage ds = LibDiamond.diamondStorage();
uint256 numFacets = ds.facetAddresses.length;
facets_ = new Facet[](numFacets);
for (uint256 i; i < numFacets; i++) {
address facetAddress_ = ds.facetAddresses[i];
facets_[i].facetAddress = facetAddress_;
facets_[i].functionSelectors = ds.facetFunctionSelectors[facetAddress_].functionSelectors;
}
}
/// @notice 获取 facet 提供的所有函数选择器。
/// @param _facet facet 地址。
/// @return facetFunctionSelectors_
function facetFunctionSelectors(address _facet) external override view returns (bytes4[] memory facetFunctionSelectors_) {
LibDiamond.DiamondStorage storage ds = LibDiamond.diamondStorage();
facetFunctionSelectors_ = ds.facetFunctionSelectors[_facet].functionSelectors;
}
/// @notice 获取一个钻石使用的所有 facet 地址。
/// @return facetAddresses_
function facetAddresses() external override view returns (address[] memory facetAddresses_) {
LibDiamond.DiamondStorage storage ds = LibDiamond.diamondStorage();
facetAddresses_ = ds.facetAddresses;
}
/// @notice 获取支持给定选择器的 facet。
/// @dev 如果找不到 facet,返回 address(0)。
/// @param _functionSelector 函数选择器。
/// @return facetAddress_ facet 地址。
function facetAddress(bytes4 _functionSelector) external override view returns (address facetAddress_) {
LibDiamond.DiamondStorage storage ds = LibDiamond.diamondStorage();
facetAddress_ = ds.selectorToFacetAndPosition[_functionSelector].facetAddress;
}
// 此实现 ERC-165。
function supportsInterface(bytes4 _interfaceId) external override view returns (bool) {
LibDiamond.DiamondStorage storage ds = LibDiamond.diamondStorage();
return ds.supportedInterfaces[_interfaceId];
}
}
diamond-1
和 diamond-2
的视图函数更复杂,因为它们必须循环遍历所有函数选择器以列出 facet 地址,然后仅返回唯一地址。
部署和升级一个钻石的过程与其他可升级代理模式相同:
在部署时我们:
在升级时:
在部署代理时,我们可能想要设置一些初始状态变量,就像我们拥有一个构造函数一样。类似地,我们可能想在升级后初始化一些存储变量,类似于在 OpenZeppelin 实现的 Transparent Upgradeable Proxy 和 UUPS 中的 upgradeToAndCall
。
这就是 diamondCut
中最后两个参数 _init
和 _calldata
的用处:
function diamondCut(Facets[] facets, address _init, bytes memory _calldata)
如果 _init ≠ address(0)
,则 diamondCut
必须使用 _calldata
作为参数代理调用 _init
。因为 diamondCut
在代理(钻石)的上下文中运行,所以被代理调用的合约(_init
)可以初始化代理中的存储变量。
初始化逻辑由外部合约执行。如果我们想在单个事务中设置多个存储变量,使用一个专门的智能合约作为单个事务来完成将更容易。
下面是一个示例合约:
import {LibDiamond} from "../libraries/LibDiamond.sol";
contract DiamondInit {
function init() external {
// 从存储读取 ds 结构
// (记住,这在代理的上下文中执行)
LibDiamond.DiamondStorage storage ds = LibDiamond.diamondStorage();
// 写入
ds.owner = 0x456....;
ds.usdc = 0xa1...;
// ...
}
}
对 diamondCut
的调用将传递 DiamondInit
的地址和 init()
的 ABI 编码。
初始化可以在发生 diamondCut
事务的任何时候进行 — 它并不限于 facet 的初始部署。我们还可以在替换或删除函数选择器时原子性地代理调用外部合约。
EIP-2535 并不要求对 DiamondInit
合约进行安全检查,以确保其实际为合约,但检查 _init
的地址确实具有字节码会更安全。为了进一步安全,ZkSync 的钻石实现 检查对 _init
合约的代理调用是否返回一个魔法值。
使用钻石标准的最安全方式是使用上面链接的参考合约,因为这些合约已经经过审计。如果你不打算在链上调用 IDiamondLoupe
函数(通常是这种情况),则 diamond-2
将是最节省 gas 的,因为它使用了符合 ERC-2535 所需的最少存储变量。
对于非可升级的钻石,我们建议硬编码函数选择器之间的关系。与其使用长长的 if-else 语句来查找 facet 地址,不如使用二分查找。
作为示例,考虑以下函数,它接受一个函数选择器作为参数并返回一个地址(代码摘自 Pendle Finance,它使用钻石代理):
该代码在函数选择器上进行二分查找,并返回实现合约(facet)的地址(持有该函数的合约)。这就是全部。
上面的地址是可变地址,在构造函数中设置,并发出如要求一样的 DiamondCut 事件:
任何写入或读取非命名空间存储的 facet 都可能遭遇存储碰撞。我们建议使用 EIP-7201 来管理所有存储。
当一个 facet(实现合约)中的函数运行时,它是在钻石(代理合约)的上下文中执行的。因此,要调用另一个 facet 中的公共函数,我们可以调用代理地址。
考虑我们最初的示例,其中我们有一个 facet Add
和一个函数 add()
,还有一个单独的 facet Multiply
。假设我们想从 Multiply
facet 调用 add()
。下面我们展示如何实现:Multiply
facet 通过指定钻石代理的地址调用 add()
函数:
interface IAdd {
function add(uint256 x, uint256 y) external view returns (uint256);
}
contract Multiply {
function callAdd(uint256 x, uint256 y) external {
uint256 sum = IAdd(address(this)).add(x, y);
// 代码的其余部分
}
}
这将以两次调用的方式运行:
callAdd
调用自身(在代理的上下文中)有些开发人员可能会惊讶于合约以这种方式调用自身,但这一确实得到了 EVM 的允许。
你可以测试以下合约以证明这一点:
contract SelfCall {
uint256 public x = 0;
function setToOne() external {
x = 1;
}
function selfCall() external {
SelfCall(address(this)).setToOne();
// 或者,
// address(this).call(abi.encodeWithSignature("setToOne()"));
}
}
然而,进行自我调用是有些浪费的。为了节省 gas,一个 facet 可以“直接”代理调用另一个 facet。幕后中,调用 facet 的逻辑在代理中运行,逻辑正在对另一个 facet 进行代理调用,因此该 facet 可以“看到”代理中的存储变量。为此完成的代码不会那么优雅,因为代理调用只能是低级调用。以下是摘自 EIP-2535 的示例代码:
// 从 selector => facet 地址获取映射
DiamondStorage storage ds = diamondStorage(); // EIP-7201
// 计算选择器
bytes4 functionSelector = bytes4(keccak256("functionToCall(uint256)"));
// 获取 facet 地址
address facet = ds.selectorToFacet[functionSelector];
// 代理调用
bytes memory myFunctionCall = abi.encodeWithSelector(functionSelector, 4);
(bool success, bytes memory result) = address(facet).delegatecall(myFunctionCall);
然而,上面的代码不够优雅,需要额外的三四行代码来完成简单的调用。
第三种解决方案是创建一个只包含内部函数的 Solidity 库,然后在任何需要这些内部函数的 facet 中导入它们。这可能会导致在 facets 之间的字节码重复,但如果这些 facets 不超出 24kb 限制或者大大增加部署成本,这不应该成为主要问题。
我们将构建一个作为计数器合约的钻石。一个 facet 将保存计数器值的视图函数,另一个 facet 将保存递增计数器的逻辑。
为了说明钻石初始化逻辑,我们将使我们的计数器从 8 开始,而不是 0。
要开始,请从 diamond-2 参考 hardhat 仓库 分叉。我们不知道任何经过审计的 Foundry 实现。
将读取命名空间存储的代码放入一个库中,方便 facets 导入是很有帮助的。将递增逻辑直接放入库中是一个设计选择。将递增逻辑直接放在库中,对于每个调用该库中函数的其它 facet 来说,将会增加字节码大小。然而,它也简化了递增 facet 的逻辑,因为递增 facet 不需要了解命名空间存储的结构。
请注意,库中的所有函数必须是内部的,因为 Solidity 编译器期望具有外部函数的库单独部署。
pragma solidity ^0.8.0;
library LibInc {
// keccak256(abi.encode(uint256(keccak256("RareSkills.Facet.Increment")) - 1)) ^ bytes32(uint256(0xff))
bytes32 constant STORAGE_LOCATION = 0xfa04c3581a2244f8cd60ed05a316a89d13b0e00f0bfbe2b8a2155985a9d65e00;
struct IncrementStorage {
uint256 x;
}
function incStorage()
internal
pure
returns (IncrementStorage storage iStor) {
bytes32 location = STORAGE_LOCATION;
assembly {
iStor.slot := location
}
}
function x()
internal
view
returns (uint256 x) {
x = incStorage().x;
}
function increment() internal {
incStorage().x++;
}
}
添加文件 contracts/libraries/LibInc.sol
初始化发生在一个合约中,该合约被 diamondCut()
合约进行代理调用。参考实现中,这在 contracts/upgradeInitializers/DiamondInit.sol 中。
由于篇幅原因,我们不展示整个合约。将以下代码添加到 DiamondInit.sol
中:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
// 添加这一行
import {LibInc} from "../libraries/LibInc.sol";
//...
contract DiamondInit {
// 你可以在这个函数中添加参数,以便传入
// 数据来设置你自己的状态变量
function init() external {
// ...
// 添加以下行
LibInc.IncrementStorage storage _is = LibInc.incStorage();
_is.x = 8; // 将 x 初始化为 8
}
}
在 contracts/facets/
中创建一个新文件 LibIncFacet.sol
:
pragma solidity ^0.8.0;
import { LibInc } from "../libraries/LibInc.sol";
contract IncViewFacet {
function x() external view returns (uint256 x) {
x = LibInc.x();
}
}
在 contracts/facets
中创建 IncrementFacet.sol
:
import { LibInc } from "../libraries/LibInc.sol";
contract IncrementFacet {
function increment() external {
LibInc.increment();
}
}
将以下测试添加到 test/diamondTest.js
中:
it.only('test increment', async () => {
const incViewInterface = await ethers.getContractAt('IncViewFacet', diamondAddress)
const initialX = await incViewInterface.x()
console.log(initialX.toString())
assert.equal(initialX, 8)
const incrementInterface = await ethers.getContractAt('IncrementFacet', diamondAddress)
await incrementInterface.increment();
const afterX = await incViewInterface.x()
assert.equal(afterX, 9)
});
打开 scripts/deploy.js
并添加 facets 的合约名称。Hardhat 足够智能,可以在没有指定文件路径的情况下找到合约。下面的更改如下图红框所示:
将 diamondTest.js
文件中的 before
钩子更新为以下内容,以部署新的 facets。
简单提一下,Solidity 编译器能够自动输出公共/外部函数的函数选择器。例如,假设我们有以下存储在 C.sol
的合约:
contract C {
function foo() public {}
function bar() external {}
}
如果我们在合约上运行 solc --hashes C.sol
,我们将得到以下输出:
======= C.sol:C =======
Function signatures:
febb0f7e: bar()
c2985578: foo()
本仓库提供的脚本使用这种技术提取函数选择器,为我们省去了显式指定函数选择器的麻烦。
同样,hardhat 能够在没有指定文件路径的情况下找到合约:
运行测试:
npx hardhat test
请注意,其他测试将失败,因为它们并不期望新的选择器。我们通过在测试中使用 .only
修饰符来忽略这一点。.only
修饰符将防止运行其他单元测试,仅运行修改了 .only
的单元测试。
如果你遇到问题,请确保使用 Node 版本 20。
calldata
的函数选择器哪个 facet 进行代理调用。diamondCut
进行更改。更改该映射的逻辑并不在标准中规定,它可以是代理字节码的一部分或某个 facet 的一部分。IDiamondLoupe
中的公共函数确定。IDiamondLoupe
中查看函数的 gas 成本可以通过添加更多数据结构来降低。然而,这增加了升级成本。在几乎所有情况下,我们应选择更少的数据结构,因为 IDiamondLoupe
函数被用于链下用户。我们要感谢 Nick Mudge 对本文章早期版本提出的意见。
- 原文链接: rareskills.io/post/diamo...
- 登链社区 AI 助手,为大家转译优秀英文文章,如有翻译不通的地方,还请包涵~
如果觉得我的文章对您有用,请随意打赏。你的支持将鼓励我继续创作!