ERC-7201(前身为 EIP-7201)是一个通过称为“命名空间”(namespace)的公共标识符将存储变量进行分组,并通过 NatSpec 注释对该变量组进行文档化的标准。该标准的目的是简化升级期间对存储变量的管理。
命名空间
命名空间是编程语言中组织和分组相关标识符(如变量、函数、类或模块)以防止命名冲突的常用方法。Solidity 原生并不具备命名空间的概念,但我们可以对其进行模拟。在我们的场景中,我们希望将合约的状态变量分组到一个命名空间中。
在 Solidity 中使用命名空间的想法并非由 ERC-7201 首次提出;钻石代理模式(ERC-2535)也使用了这一概念。要理解在可升级智能合约中使用命名空间的重要性,首先必须了解 ERC-7201 旨在解决的问题。
继承带来的问题
为了演示,让我们考察一个由代理合约和通过父子合约继承构建的实现合约组成的可升级合约。在实现端,我们有一个父合约和一个子合约,每个合约在其初始插槽中都包含一个状态变量。这些实现合约的存储结构将被复制到代理合约中,该代理合约可以是一个 transparent proxy。为了简单起见,我们假设每个变量恰好占用一个插槽,这意味着我们只使用如 uint256 或 bytes32 这样的变量。

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

这个场景带来了一个挑战:新的 variableC 将被放置在之前 variableB 所在的位置。升级破坏了存储布局,导致新的 variableC 读取了旧的 variableB 的值,这便是一个插槽碰撞。
间隙方法
OpenZeppelin 在其 v4 及之前的可升级合约中,通过在每个合约的末尾插入一个“间隙”(gap)来解决这个问题。 在下方,我们可以观察到 ERC20Upgradeable.sol v4.9 合约的代码。
![code snippet for uint256[45] private __gap variable](https://static.wixstatic.com/media/706568_b9dbd4392cf641d296c29878da33df6d~mv2.png/v1/fill/w_740,h_197,al_c,q_85,usm_0.66_1.00_0.01,enc_auto/706568_b9dbd4392cf641d296c29878da33df6d~mv2.png)
__gap 变量的大小经过精确计算,以便合约始终使用 50 个可用的存储插槽,因此上图中显示的合约包含 5 个状态变量。让我们将这个概念引入我们的示例中。
如果包含 5 个状态变量的父合约包含一个具有 45 个空插槽的数组作为间隙,那么实现(及代理)合约的存储结构将如下方图片所示。

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


间隙使得向合约中插入新变量变得容易,且不会破坏现有功能,它充当了未来新增内容的占位符,避免了存储碰撞。在使用这种方法时,建议在所有实现合约中都包含一个间隙。
虽然这种方法缓解了在父合约中插入变量的问题,但它并没有完全解决与修改实现合约布局相关的所有问题。例如,如果我们在当前的父合约之上创建一个新的父合约,那么下方所有的内容都将向下平移新父合约中存储变量数量的位置,因此仅仅依赖间隙是无效的。

因此,找到一种既能调整实现合约布局又不会产生插槽碰撞的方法至关重要。
最优解决方案将涉及为继承链中的每个实现合约分配其自身专用的存储位置。
遗憾的是,Solidity 目前缺乏实现这一点的原生机制(为合约中的变量提供命名空间)。因此,这种特性的构建必须在 Solidity 和 YUL 的范围内实现。这可以通过使用 structs 来实现。让我们回顾一下 Solidity 中的存储布局是如何工作的,以及如何建立基于命名空间的根布局。
基于命名空间的根布局
Solidity 生成的合约存储布局可以概括如下,其中 L 表示存储中的位置,n 是自然数,H(k) 是应用于特定类型键 k 的函数,例如,这可以是一个 mapping 的键或数组的索引。
上面的公式表明状态变量可以在以下位置找到:
- 在根中,默认是插槽 0,
- 语法的任何元素加上一个自然数。
- 在由某个键及其相较于根的位置共同确定性计算得出的值的 keccak 哈希内部。
我们需要认识到的是,存储布局中的所有位置都依赖于根。 Solidity 为任何合约的根都赋予了值零。
如果我们要为存储合约的变量创建自己的位置,我们需要基于对该合约唯一的某个标签来“更改”根。正是这个标签,我们将其定义为合约的命名空间。
智能合约中命名空间的概念 旨在确保使用命名空间的合约的存储布局根不再位于插槽零,而是位于由所选命名空间决定的特定插槽中。

仅仅使用 Solidity 是无法实现这一点的,因为编译器始终使用插槽零作为存储布局的根,但我们很快就会看到,可以使用 structs 和 assembly 找到一种方法。
在此之前,我们将研究 ERC-7201 提出的公式,该公式用于从作为命名空间的字符串中计算新根的值。
计算基于命名空间的存储根的推荐公式
如果我们要“更改”命名空间合约的根存储插槽,我们需要定义一个公式来计算这个新根。此 ERC 中提出的公式如下:
keccak256(keccak256(namespace) - 1) & ~0xff
该公式背后的基本原理如下:
- 在生成 keccak256 命名空间后减去 1 可确保哈希原像保持未知。
- 第二次进行 keccak256 哈希计算有助于防止与 Solidity 生成的插槽发生潜在冲突,因为存储中动态大小变量的位置是由 keccak256 哈希决定的。
- 执行 AND NOT 0xff 操作将该位置最右边的字节转换为 00。这是为将来 Ethereum 将其存储数据结构切换为 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 开始分配变量,这正是我们想要避免的。
为了将变量组合在一起,我们使用 struct。在 struct 内部,字段遵循常规的存储插槽排序。考虑以下合约:
contract StructStorage {
// **ERC-7201 uses a struct to group variables together, but the struct is never
// actually declared, nor any other state variable.**
struct MyStruct {
uint256 fieldA;
uint256 fieldB;
mapping(address => uint256) fieldC;
}
// Contract functions...
}
假设如果我们将这个 struct 声明为第一个存储变量(ERC-7201 并未 这么做),fieldA 将在插槽 0,fieldB 将在插槽 1,fieldC mapping 的基址将在存储插槽 2,依此类推。寻找可写入 struct 类型变量字段的存储位置的公式如下,其中 struct base(结构体基址)是该 struct 开始占用存储插槽的所在插槽。
请注意,这与前面的存储布局公式相同;我们只是用 struct 的基址替换了根,也就是说,struct 通过其字段维护了存储布局。这意味着我们可以使用 struct 的基址作为新的根。
在上面的示例中,struct 的基址是插槽零,但我们可以选择另一个插槽作为 struct 的基址。这可以使用 YUL 来完成,如下面的示例所示。
contract StructOnStorage {
// NO STATE VARIABLES
struct MyStruct{
uint256 fieldA;
mapping(uint => uint) fieldB;
}
function setMyStruct() public {
MyStruct storage myStruct; // Grab a struct
assembly {
myStruct.slot := 0x02 // Change its base slot
}
myStruct.fieldA = 100; // FieldA will be in the first slot from the base at 0x02, which is 0x02 itself
myStruct.fieldB[10] = 101; // The storage address of this mapping item will be calculated below
}
function getMyStruct() public view returns (uint256 fieldA, uint256 fieldBSingleValue) {
// keccak256(abi.encode(key, struct base + location inside the struct)
// The mapping is located in the second slot inside the struct, so struct base + 1
bytes32 locationSingleValue = keccak256(abi.encode(0x0a, 0x02 + 1));
assembly {
fieldA := sload(0x02) // Read storage at 0x02
fieldBSingleValue := sload(locationSingleValue)
}
}
}
当我们使用 myStruct.slot := 0x02 语句时,我们显式更改了 struct 的基址,并能够模拟一种根不再位于插槽零的存储布局。在 struct 内部,我们必须将原本应作为状态变量的所有变量放置为 struct 字段。 struct 基址充当了其字段的新根,这正是我们想要达到的目标。
这种方法的一个缺点是,每次保存或读取 struct 字段时,我们都需要显式地指出 struct 的基址。
由于我们始终需要引用 struct 的基址,因此建议创建一个实用函数来执行此操作。在 OpenZeppelin 的可升级合约中,有一个私有函数专门用于创建指向 struct 基址的指针。例如,在 ERC20Upgradeable.sol:

下面我们可以看到,所有“本应是”状态变量的变量是如何必须被声明为 struct 字段的。
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;
}
让我们看一个示例,了解如何使用实用函数来检索 struct 字段,例如 ERC20Upgradeable.sol 合约的 token name。
/**
* @dev Returns the name of the token.
*/
function name() public view virtual returns (string memory) {
ERC20Storage storage $ = _getERC20Storage();
return $._name;
}
正如上方所见,当我们想要检索存储变量时,我们只需调用 _getERC20StorageLocation(),它会以 bytes32 的形式返回命名空间存储根。
当我们想要更新字段时,同样适用。$ 指针位于 struct 的基址,因此我们可以使用 $.[field] 语法来读取/更新字段。在下图中,我们看到了来自 ERC20Upgradeable.sol 合约中 _update 函数代码的片段,以及它是如何在转账期间用于更新余额的。

如何实现基于命名空间的根布局总结
要实现此模式,只需遵循以下步骤:
- 不要使用状态变量。
- 应当作为状态变量的内容必须定义为 struct 中的字段。
- 为合约选择一个唯一的命名空间。
- 使用一个函数根据命名空间计算该合约的新根。ERC-7201 提出了一种建议使用的函数。
- 创建一个实用函数来返回对 struct 基址的引用。使用 assembly 显式表明 struct 基址所在的插槽是前一项中定义的函数计算出的插槽。
- 每次读取或更新 struct 字段时,都使用该实用函数指向 struct 的基址。
在下一节中,我们将了解如何记录合约内部使用命名空间的情况。
用于自定义存储位置的 NatSpec
Ethereum Natural Language Specification Format(简称 NatSpec)是一种作为合约内文档的注释方法。以下是一个记录函数的 NatSpec 注释示例:
/**
* @dev Returns the name of the token.
*/
ERC-7201 的目标之一是提出一种在 NatSpec 中记录命名空间使用情况的方法:
@custom:storage-location <FORMULA_ID>:<NAMESPACE_ID>
FormulaID 代表用于从命名空间计算存储根的公式,而 namespaceId 则指的是正在考虑的特定命名空间。被注释的对象是 struct,因此注释必须直接位于其上方。
在此 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) 对本文早期草稿提供的宝贵意见。
原载于 6 月 13 日