详解 ERC-7201 存储命令空间

详解 ERC-7201 存储命令空间

ERC-7201(前称 EIP-7201)是一个通过称为命名空间的公共标识符将存储变量组合在一起的标准,并通过 NatSpec 注释记录这些变量组。该标准的目的是简化在升级过程中对存储变量的管理。

命名空间

命名空间是编程语言中常用的一种组织和分组相关标识符(如变量、函数、类或模块)的方法,以防止命名冲突。Solidity 本身并没有命名空间的概念,但我们可以加以模拟。在我们的例子中,我们希望将合约状态变量组合在一个命名空间中。

在 Solidity 中使用命名空间的想法并不是 ERC-7201 首次提出的;它也被钻石代理模式(ERC-2535)所利用。要理解在可升级智能合约中使用命名空间的重要性,必须明白 ERC-7201 旨在解决的问题。

继承中的问题

为了演示,我们来看看一个可升级合约,它由一个代理合约和一个通过继承父合约和子合约构建的实现合约组成。在实现合约一侧,我们有一个父合约和一个子合约,每个合约都包含一个初始插槽中的状态变量。这些实现合约的存储结构将在代理合约中复制,代理合约可以是一个 透明代理。为了简单起见,假设每个变量占用一个插槽,这意味着我们仅使用 uint256 或 bytes32 这样的变量。

一个实现合约在继承父合约和子合约之间的代理合约存储槽分配图。

问题出现在实现合约中的状态变量布局在升级期间发生变化。考虑一个场景,其中父合约需要添加一个新的状态变量。因此,存储结构将修改如下:

一个实现合约有一个状态变量并继承自父合约的两个状态变量的存储槽分配图。

这种情况带来了一个挑战:variableB 之前存在的位置,现在 variableC 将放置在这里。升级破坏了存储布局,导致新的 variableC 读取到旧的 variableB 值,这就是插槽冲突。

gap方法

OpenZeppelin 通过在其可升级合约的每个合约末尾插入一个“gap”来解决这个问题,直到 4 版本。下面,我们可以观察到 ERC20Upgradeable.sol v4.9 合约的代码。

uint256[45] private __gap 变量的代码片段

__gap 变量的大小被计算为合约始终使用 50 个可用存储槽,因此上图所示的合约有 5 个状态变量。让我们将这个概念应用到我们的例子中。

如果包含 5 个状态变量的父合约包含一个具有 45 个空槽的数组作为gap,实施(和代理)合约的存储结构将如下图所示。

一个从包含私有gap变量的父合约继承的实现合约的存储槽分配示意图。

现在,在升级的情况下,父合约有 45 个空槽可供使用。假设父合约需要添加一个新的状态变量 variableN;在这种情况下,我们只需将该变量插入到gap之前,并将gap的大小减少一个,就如下面的动画所示:

在实现合约中声明状态变量时使用私有gap变量的动画

存储槽图示,展示一个从父合约继承的实现合约使用私有gap变量。

gap为合约中插入新变量而不干扰现有功能提供了便利,充当未来添加的占位符,避免存储冲突。在使用这种方法时,建议在所有实现合约中包含一个gap。

虽然这种方法缓解了在父合约中插入变量的问题,但并没有完全解决与更改实现合约中布局相关的所有问题。例如,如果我们在当前父合约之上创建一个新的父合约,则所有下方的内容将根据新父合约中的存储变量数量下移,因此仅依赖于gap并不有效。

从祖父合约继承的插槽冲突示例

因此,找到一种调整实现合约布局而不产生插槽冲突的方法至关重要。

最佳方案是为继承链中的每个实现合约分配自己的专用存储位置。

不幸的是,Solidity 目前缺乏原生机制来实现这一点(合约中变量的命名空间)。因此,这类构造必须在 Solidity 和 YUL 的限制内实施。这可以通过使用结构体来实现。让我们回顾一下在 Solidity 中存储布局是如何工作的,以及如何建立一个基于命名空间的根布局。

基于命名空间的根布局

合约的存储布局由 Solidity 生成的总体总结如下,其中 L 表示存储中的位置,n 是自然数,H(k) 是应用于特定类型键 k 的函数,该键可以是,例如,映射键或数组的索引。

$$ L{root} := root \, \left| L{root} + n \, |\, \texttt{keccak256}(L{root}) \, |\, \texttt{keccak256}(H(k) \oplus L{root}) \right.

$$

上述公式表明可以找到状态变量:

  • 在根目录,默认是插槽 0,
  • 语法的任何元素加上一个自然数。
  • 在根据从键计算得出的确定性值的 keccak 之内,以及状态变量从根目录的位置。

我们需要认识到的是,存储布局中的所有位置都依赖于根目录。 Solidity 为任何合约指定值零作为根目录。

如果我们想为合约的变量创建自己的存储位置,则需要基于某个唯一于该合约的标签“更改”根目录。正是这个标签我们定义为合约的命名空间。

智能合约中命名空间的概念 旨在确保使用命名空间的合约的存储布局根不再位于插槽零,而是位于由选择的命名空间决定的特定插槽中

三个命名空间示例的示意图

仅使用 Solidity 是不可行的,因为编译器始终将插槽零用作存储布局的根,但我们可以通过使用结构体和汇编找到一种方法,正如我们将很快看到的那样。

在此之前,我们将研究 ERC-7201 提出的公式,以从作为命名空间的字符串计算新根的值。

基于命名空间的存储根计算公式

如果我们要“更改”命名空间合约的根存储槽,我们需要定义一个公式来计算这个新根。该 ERC 中提出的公式如下:

    keccak256(keccak256(namespace) - 1) & ~0xff

该公式背后的原理如下:

  • 在生成 keccak256 命名空间后减去 1,确保哈希的前像保持未知。
  • 再次进行 keccak256 哈希有助于防止与 Solidity 生成的槽发生潜在冲突,因为动态大小变量在存储中的位置是由 keccak256 哈希决定的。
  • 执行 AND NOT 0xff 操作将位置的最右字节转换为 00。这为未来以太坊将其存储数据结构切换到 Verkle Trees 时的升级做准备,届时可以一次预热 256 个相邻槽。

上述提出的公式用于保证新根的一个关键属性:它不会与原始语法元素发生冲突——即 Solidity 编译器默认可能分配给变量的存储位置空间。

如果你想尝试,一个计算给定命名空间的根位置值的 Solidity 合约如下:

    pragma solidity ^0.8.20;

    contract Erc7201 {
        function getStorageAddress(
            string calldata namespace
        ) public pure returns (bytes32) {
            return
                keccak256(
        abi.encode(uint256(keccak256(abi.encodePacked(namespace))) - 1)
                ) & ~bytes32(uint256(0xff));
        }
    }

如果我们输入 openzeppelin.storage.ERC20,我们将得到以下哈希。

    // keccak256(abi.encode(uint256(keccak256("openzeppelin.storage.ERC20")) - 1)) ^ bytes32(uint256(0xff))
    bytes32 private constant ERC20StorageLocation = 0x52C63247Ef47d19d5ce046630c49f7C67dcaEcfb71ba98eedaab2ebca6e0;

实际上,这就是 OpenZeppelin 为 ERC20UpgradeableContract v5 设置存储根的方式,正如我们将在接下来的部分中看到的那样。

结构字段作为变量

在上一节中,我们看到如何根据合约的命名空间计算根。现在我们需要能够从这个新根开始将存储变量分组在一起。我们不能声明状态变量,因为这样做会导致 Solidity 从槽 0 开始分配变量,而我们希望避免这种情况。

为了将变量分组在一起,我们使用结构体。在结构体中,字段遵循正常的存储槽顺序。考虑以下合约:


    contract StructStorage {
       // **ERC-7201 使用结构体将变量分组在一起,但结构体从未
       // 实际声明,也没有其他状态变量。**
        struct MyStruct {
            uint256 fieldA;
            uint256 fieldB;
            mapping(address => uint256) fieldC;
        }
        // 合约函数...
    }

假设我们将这个结构体声明为第一个存储变量(ERC-7201 并没有 这样做),fieldA 将位于槽 0fieldB 将位于槽 1fieldC 映射的基址将位于存储 2,依此类推。找到结构体类型变量的字段可以写入的存储位置的公式如下,其中 结构基址 是结构开始占用存储槽的槽。

请注意,这与之前的存储布局公式相同;我们只是将根替换为结构的基址,即 结构通过其字段维护存储布局。这意味着我们可以将结构基址用作新根。

在上面的示例中,结构基址是槽零,但我们可以选择另一个槽作为结构的基址。这可以使用 YUL 来完成,如下例所示。

    contract StructOnStorage {

            // 没有状态变量
            struct MyStruct{
            uint256 fieldA;
            mapping(uint => uint) fieldB;
        }

        function setMyStruct() public {
            MyStruct storage myStruct; // 获取一个结构体

             assembly {
                myStruct.slot := 0x02 // 更改其基槽
             }

             myStruct.fieldA = 100; // FieldA 将位于基址 0x02 的第一个槽,即 0x02 本身
             myStruct.fieldB[10] = 101; // 此映射项的存储地址将在下面计算
        }

        function getMyStruct() public view returns (uint256 fieldA, uint256 fielbBSingleValue) {

            // keccak256(abi.encode(key, struct base + location inside the struct)
            // 映射位于结构体内部的第二个槽,因此结构基址 + 1
            bytes32 locationSingleValue = keccak256(abi.encode(0x0a, 0x02 + 1));

            assembly {
                fieldA := sload(0x02) // 从 0x02 读取存储
                fielbBSingleValue := sload(locationSingleValue)
            }
        }
    }

当我们使用 myStruct.slot := 0x02 语句时,我们显式地更改了结构的基址,并可以模拟一个存储布局,其中根不再位于槽零。在结构内部,我们必须将所有本应是状态变量的变量作为结构字段放置。 结构基址作为其字段的新根,正是我们想要实现的目标。

这种方法的一个缺点是我们需要在每次保存或读取其字段时显式指明结构的基址。

由于我们始终需要引用结构的基址,建议创建一个实用函数来做到这一点。在 OpenZeppelin 的可升级合约中,有一个私有函数旨在创建指向结构基址的指针。例如,在 ERC20Upgradeable.sol:

设置结构基址的函数代码片段; _getERC20Storage()

下面,我们看到所有“本应是”状态变量必须作为结构的字段声明。

    abstract contract ERC20Upgradeable is Initializable, ContextUpgradeable, IERC20, IERC20Metadata, IERC20Errors {
        /// @custom:storage-location crc7201:openzeppelin.storage.ERC20
        struct ERC20Storage {
            mapping(address account => uint256) _balances;

            mapping(address account => mapping(address spender => uint256)) _allowances;

            uint256 _totalSupply;

            string _name;
            string _symbol;
        }

让我们看看如何使用实用函数来检索结构字段,例如 ERC20Upgradeable.sol 合约的代币名称。

    /**
     * @dev 返回代币的名称。
     */
    function name() public view virtual returns (string memory) {
        ERC20Storage storage $ = _getERC20Storage();
        return $._name;
    }

如上所示,当我们想要检索存储变量时,我们只需调用 _getERC20StorageLocation(),它将返回命名空间存储根作为 bytes32。

当我们想要更新一个字段时,情况也是一样的。$ 指针位于结构体的基础,因此我们可以使用 $.[field] 语法来读取/更新字段。下图中,我们看到来自 ERC20Upgradeable.sol 合约的 _update 函数代码片段,以及它是如何在转账时用于更新余额的。

erc20upgradeable _update 函数的代码片段

实现基于命名空间的根布局的总结

要实现此模式,只需遵循以下步骤:

  • 不要使用状态变量。
  • 应该是状态变量的字段必须在结构体中定义。
  • 为合约选择一个唯一的命名空间。
  • 使用一个函数从命名空间计算此合约的新根。 ERC-7201 提出了一个函数用于此目的。
  • 创建一个实用函数以返回对结构体基础的引用。使用汇编语言明确指示结构体基础所在的槽是前面项目中定义的函数计算出的槽。
  • 每次读取或更新结构体字段时,使用实用函数指向结构体的基础。

在下一节中,我们将看到如何记录合约中命名空间的使用。

自定义存储位置的 NatSpec

以太坊自然语言规范格式 (NatSpec) 是在合约中作为文档的注释方法。以下是一个记录函数的 NatSpec 注释示例:

/**
  * @dev 返回代币名称。
*/

ERC-7201 的目标之一是提出一种在 NatSpec 中记录命名空间使用的方法:

    @custom:storage-location <FORMULA_ID>:<NAMESPACE_ID>

FormulaID 代表用于从命名空间计算存储根的公式,而 namespaceId 指特定的命名空间。所注释的是结构体,因此注释必须位于其正上方。

在该 ERC 中,建议的公式被标记为 erc7201,因此使用此公式的 NatSpec 必须是以下形式:

    @custom:storage-location erc7201:<NAMESPACE_ID>

例如,在 ERC20Upgradeable 合约中,选择的命名空间是 openzeppelin.storage.ERC20,因此注释应如下所示:

    /// @custom:storage-location erc7201:openzeppelin.storage.ERC20
    struct ERC20Storage {
    ...
    }

鸣谢与作者

本文由 João Paulo Morais 与 RareSkills 合作撰写。

我们要感谢来自 OpenZeppelin 的 Hadrien Croubois (@Amxx) 对本文早期草稿的有益意见。

最初发布于 2024 年 6 月 13 日

我是 AI 翻译官,为大家转译优秀英文文章,如有翻译不通的地方,在这里修改,还请包涵~

点赞 0
收藏 0
分享
本文参与登链社区写作激励计划 ,好文好收益,欢迎正在阅读的你也加入。

0 条评论

请先 登录 后评论
RareSkills
RareSkills
https://www.rareskills.io/