Alert Source Discuss
⚠️ Review Standards Track: ERC

ERC-7813: 存储,基于表格的可内省存储

用于自动索引和可内省状态的链上表

Authors alvarius (@alvrs), dk1a (@dk1a), frolic (@frolic), ludens (@ludns), vdrg (@vdrg), yonada <yonada@proton.me>
Created 2024-11-08

摘要

本标准引入了一种灵活的链上存储模式,该模式将数据组织成结构化表格,这些表格由具有固定键和值模式的记录组成,类似于传统数据库。这种存储模式包含一个统一的合约接口用于数据访问,以及一个紧凑的二进制编码格式,用于静态和动态数据类型。状态变更通过标准化事件进行跟踪,从而实现链下索引器的自动、模式感知状态复制。新的表格可以在运行时通过一个特殊的表格动态注册,该表格存储所有表格的模式元数据,允许系统在不破坏现有合约或集成的情况下进行演变。

动机

智能合约中缺乏一致的链上数据管理标准,可能导致僵化的实现、合约逻辑与链下服务的紧密耦合,以及在不破坏现有集成的情况下更新或扩展合约数据布局的挑战。

使用本 ERC 中定义的存储机制提供以下好处:

  1. 自动索引:通过在状态更改期间发出一致的、标准化的事件,链下服务可以自动跟踪链上状态并提供模式感知的索引器 API。
  2. 消除自定义 Getter 函数:任何合约或链下服务都可以通过一致的接口读取存储的数据,从而将智能合约的实现与特定的数据访问模式解耦,并减少开发开销。
  3. 更简单的可升级性:此模式利用非结构化存储,从而更容易升级合约逻辑,而不会出现使用固定存储布局相关的风险。
  4. 灵活的数据扩展:可以在运行时添加新表,而不会破坏与其他数据使用者的现有集成。
  5. 降低 gas 成本:使用高效的数据打包可以降低存储和事件发出的 gas 成本。

规范

定义

存储 (Store)

一个实现了本 ERC 提出的接口并将数据组织成表格的智能合约。它为每个数据操作发出事件,以便链下组件可以复制所有表格的状态。

表格 (Table)

一种存储结构,用于保存共享相同 Schema(模式)Records(记录)

  • 链上表格 (On-chain Table):在链上存储其状态并为链下索引器发出事件。
  • 链下表格 (Off-chain Table):不在链上存储状态,但为链下索引器发出事件。

记录 (Record)

存储在 Table(表格) 中的一条数据,由一个或多个键寻址。

ResourceId

一个 32 字节的值,唯一标识 Store(存储) 中的每个 Table(表格)

type ResourceId is bytes32;

编码:

字节(从左到右) 描述
0-1 表格类型标识符
2-31 唯一标识符

表格类型标识符:

  • 0x7462 ("tb") 用于链上表格
  • 0x6f74 ("ot") 用于链下表格

Schema

用于表示表格中记录的布局。

type Schema is bytes32;

每个表格定义两个模式:

  • 键模式:用于唯一标识表格中的 Record(记录) 的键的类型。它仅包含固定长度的数据类型。
  • 值模式:Record(记录) 的值字段的类型,可以包括固定长度和可变长度的数据类型。
字节(从左到右) 约束
0-1 静态字段的总字节长度  
2 静态长度字段的数量 ≤ (28 - 动态长度字段的数量)
3 动态长度字段的数量 对于键模式,为 0
值模式,≤5    
4-31 每个字节编码一个 SchemaType(模式类型) 动态长度类型必须位于所有静态长度类型之后。

SchemaType

单字节,表示特定静态或动态字段的类型。

enum SchemaType { ... }

类型编码:

值范围 类型
0x000x1F uint8uint256 (以 8 位递增)
0x200x3F int8int256 (以 8 位递增)
0x400x5F bytes1bytes32
0x60 bool
0x61 address
0x620x81 uint8[]uint256[]
0x820xA1 int8[]int256[]
0xA20xC1 bytes1[]bytes32[]
0xC2 bool[]
0xC3 address[]
0xC4 bytes
0xC5 string

FieldLayout

对具体的值 Schema 信息进行编码,特别是静态字段的总字节长度、动态字段的数量以及每个静态字段自身的长度。

这种编码是对链上操作的优化。通过提供现成的精确长度,Store 在执行期间不需要重复计算或将模式定义转换为实际字段长度。

type FieldLayout is bytes32;
字节(从左到右) 约束
0-1 静态字段的总长度  
2 静态长度字段的数量 ≤ (28 - 动态长度字段的数量)
3 动态长度字段的数量 对于键模式,为 0
值模式,≤5    
4-31 每个字节编码相应静态字段的字节长度  

EncodedLengths

对特定记录的所有动态字段的字节长度进行编码。它由 Store 方法在读取记录时返回,因为解码动态字段需要用到它。

type EncodedLengths is bytes32;
字节(从最小到最大有效位) 类型 描述
0x00-0x06 uint56 动态数据的总字节长度
0x07-0xB uint40 第一个动态字段的长度
0x0C-0x10 uint40 第二个动态字段的长度
0x11-0x15 uint40 第三个动态字段的长度
0x16-0x1A uint40 第四个动态字段的长度
0x1B-0x1F uint40 第五个动态字段的长度

打包数据编码

Store 方法返回的记录数据和 Store 事件中包含的记录数据使用以下编码规则。

字段限制

  • 最大字段总数:一条记录最多可以包含 28 个字段(静态字段和动态字段加起来)。
    • 此限制是由于 Schema 类型结构使用了 28 个字节(字节 4 到 31)来定义字段类型,每个字段一个字节 (SchemaType)。
  • 动态字段限制:一条记录最多可以有 5 个动态字段
    • 这是因为使用单个 32 字节字 (EncodedLengths) 来编码每个动态字段的字节长度,而不是像 Solidity 的 abi.encode 那样单独编码每个长度。
  • 静态字段限制:静态字段的最大数量是 28 减去动态字段的数量
    • 例如,如果有 5 个动态字段,则静态字段的最大数量为 23 (28 - 5)。

编码规则

  • 静态长度字段在编码时没有任何填充,并且按照它们在模式中定义的顺序连接,这相当于使用 Solidity 的 abi.encodePacked
  • 对于动态长度字段(数组、bytesstrings):
    • 如果该字段是一个数组,则其元素会紧密打包,没有填充。
    • 所有动态字段连接在一起,没有填充,也没有包括它们的长度。
    • 所有动态字段的长度都被编码成一个 EncodedLengths

示例

假设一个表具有以下值模式:

(uint256 id, address owner, string description, uint8[] scores)

编码(伪代码)

bytes memory staticData = abi.encodePacked(id, owner);

// This is a custom function as Solidity does not provide a way to tightly pack array elements
// 这是一个自定义函数,因为 Solidity 没有提供紧密打包数组元素的方法
bytes memory packedScores = packElementsWithoutPadding(scores);

// abi.encodePacked concatenates both description and packedScores without including their lengths
// abi.encodePacked 连接 description 和 packedScores,但不包括它们的长度
bytes memory dynamicData = abi.encodePacked(description, packedScores);

// Total length is encoded in the 56 least significant bits
// 总长度编码在 56 个最低有效位中
EncodedLengths encodedLengths = dynamicData.length;

// Each length is encoded using 5 bytes
// 每个长度都使用 5 个字节进行编码
encodedLengths |= (description.length << (56));
encodedLengths |= (encodedData.length << (56 + 8 * 5));

// The full encoded record data is represented by the following tuple:
// 完整的编码记录数据由以下元组表示:
// (staticData, encodedLengths, dynamicData)

存储接口

所有存储必须实现以下接口。

interface IStore {
  /**
   * Get full encoded record (all fields, static and dynamic data) for the given tableId and key tuple.
   * 获取给定 tableId 和键元组的完整编码记录(所有字段,静态和动态数据)。
   */
  function getRecord(
    ResourceId tableId,
    bytes32[] calldata keyTuple
  ) external view returns (bytes memory staticData, EncodedLengths encodedLengths, bytes memory dynamicData);

  /**
   * Get a single encoded field from the given tableId and key tuple.
   * 从给定的 tableId 和键元组中获取单个编码字段。
   */
  function getField(
    ResourceId tableId,
    bytes32[] calldata keyTuple,
    uint8 fieldIndex
  ) external view returns (bytes memory data);

  /**
   * Get the byte length of a single field from the given tableId and key tuple
   * 从给定的 tableId 和键元组中获取单个字段的字节长度。
   */
  function getFieldLength(
    ResourceId tableId,
    bytes32[] memory keyTuple,
    uint8 fieldIndex
  ) external view returns (uint256);
}

getRecordgetField 的返回值都使用先前在打包数据编码部分中定义的编码规则。更具体地说,getRecord 返回完全编码的记录元组,并且 getField 返回的数据使用编码规则进行编码,就好像该字段是单独编码的一样。

存储操作和事件

本标准定义了三个核心操作,用于操作表中的记录:设置、更新和删除。对于每个操作,必须发出特定的事件。这些操作的实现细节由每个存储实现自行决定。

根本的要求是,对于链上表格,在任何给定的区块中通过存储接口方法检索的 Record 数据必须与通过应用存储事件所暗示的操作(直到该区块)获得的 Record 数据一致。这确保了数据完整性,并允许准确的链下状态重建。

Store_SetRecord

设置 Record(记录)意味着覆盖其所有字段。无论之前是否已设置该记录,都可以执行此操作(标准不强制执行存在性检查)。

每当记录的完整数据被覆盖时,必须发出 Store_SetRecord 事件。

event Store_SetRecord(
  ResourceId indexed tableId,
  bytes32[] keyTuple,
  bytes staticData,
  EncodedLengths encodedLengths,
  bytes dynamicData
);

参数:

名称 类型 描述
tableId ResourceId 设置记录的表格的 ID
keyTuple bytes32[] 表示记录复合键的数组
staticData bytes 使用打包编码的记录的静态数据
encodedLengths EncodedLengths 记录动态数据的编码长度
dynamicData bytes 记录的动态数据,使用 自定义打包编码

Store_SpliceStaticData

拼接(Splicing) Record(纪录)的静态数据包括覆盖打包编码静态字段的字节。静态数据的总长度不会改变,因为它由表格的值模式确定。

每当 Record(纪录)的静态数据被拼接时,务必触发 Store_SpliceStaticData 事件。

event Store_SpliceStaticData(
  ResourceId indexed tableId,
  bytes32[] keyTuple,
  uint48 start,
  bytes data
);

参数:

名称 类型 描述
tableId ResourceId 拼接数据的表格的 ID
keyTuple bytes32[] 表示记录键的数组
start uint48 拼接操作的起始位置(以字节为单位)
data bytes 使用值静态字段的元组的打包 ABI 编码

Store_SpliceDynamicData

拼接 Record 的动态数据涉及通过删除、替换和/或插入新字节来修改其动态字段的打包编码表示。

每当 Record 的动态数据被拼接时,务必触发 Store_SpliceDynamicData 事件。

event Store_SpliceDynamicData(
  ResourceId indexed tableId,
  bytes32[] keyTuple,
  uint8 dynamicFieldIndex,
  uint48 start,
  uint40 deleteCount,
  EncodedLengths encodedLengths,
  bytes data
);

参数:

名称 类型 描述
tableId ResourceId 拼接数据的表格的 ID
keyTuple bytes32[] 表示记录复合键的数组
dynamicFieldIndex uint8 要拼接数据的动态字段的索引,相对于动态字段的起始位置( 动态字段索引 = 字段索引 - 静态字段数)
start uint48 拼接操作的起始位置(以字节为单位)
deleteCount uint40 拼接操作中要删除的字节数
encodedLengths EncodedLengths 记录的动态数据的生成的编码长度
data bytes 要插入到记录动态数据的起始字节的数据

Store_DeleteRecord

每当 Record(记录)从 Table(表)中删除时,必须发出 Store_DeleteRecord 事件。

event Store_DeleteRecord(ResourceId indexed tableId, bytes32[] keyTuple);

参数:

名称 类型 描述
tableId ResourceId 删除记录的表格的 ID
keyTuple bytes32[] 表示记录复合键的数组

有关如何索引存储事件的示例,请参见参考实现部分

Tables 表格

为了跟踪每个表格的信息并支持在运行时注册新表格,存储实现必须包含一个特殊的链上 Tables 表格,该表格的行为与其他链上表格相同,但有以下特殊约束。

Tables 表格必须使用以下 Schema

  • 键模式:
    • tableId (ResourceId):此记录描述的表格的 ResourceId
  • 值模式:
    • fieldLayout (FieldLayout):编码表格中每个静态数据类型的字节长度。
    • keySchema (Schema):表示表格(复合)键的数据类型。
    • valueSchema (Schema):表示表格值字段的数据类型。
    • abiEncodedKeyNames (bytes):键名称的 ABI 编码字符串数组。
    • abiEncodedFieldNames (bytes):字段名称的 ABI 编码字符串数组。

存储在 Tables 表格中的记录被认为是不可变的:

  • Store 必须为每个注册的表格发出一个 Store_SetRecord 事件。
  • 对于在 Tables 表格中注册的 TableStore 不应发出任何其他 Store 事件。

Tables 表格必须在注册任何其他表格之前存储描述自身的记录,并发出相应的 Store_SetRecord 事件。该记录必须使用以下 tableId

// First two bytes indicates that this is an on-chain table
// 前两个字节表示这是一个链上表格
// The next 30 bytes are the unique identifier for the Tables table
// 接下来的 30 个字节是 Tables 表格的唯一标识符
// bytes32("tb") | bytes32("store") >> (2 * 8) | bytes32("Tables") >> (2 * 8 + 14 * 8)
ResourceId tableId = ResourceId.wrap(0x746273746f72650000000000000000005461626c657300000000000000000000);

通过为 Tables 表格使用预定义的 ResourceIdSchema,链下索引器可以解释所有已注册表格的存储事件。这使得能够开发在结构化数据上运行的高级链下服务,而不是像以前的索引器实现示例中那样在原始编码数据上运行。

理由

拼接事件

虽然 Store_SetRecord 事件足以跟踪每个记录的链下数据,但包括 Splice 事件(Store_SpliceStaticDataStore_SpliceDynamicData)可以实现更有效的局部更新。当只有一部分记录发生变化时,发出完整的 SetRecord 事件效率低下,因为需要从存储中读取并发出整个记录数据。Splice 事件使存储只需发出更新所需的最小数据,从而降低 gas 消耗。对于具有大型动态字段的记录,这一点尤其重要,因为更新它们的成本不会随着字段大小的增加而增加。

不允许动态类型的数组

有意不包括动态类型的数组(例如,string[]bytes[])作为支持的 SchemaType。此限制强制执行平面数据模式,从而简化存储实现并提高效率。如果用户需要存储此类数据结构,他们可以使用单独的表格对其进行建模,该表格具有类似 { index: uint256, data: bytes } 的模式,其中每个数组元素都表示为单个记录。

FieldLayout 优化

通过预先计算并存储静态字段的确切字节长度,在 Tables 模式中包含 FieldLayout 可提供链上优化。这消除了在运行时重复计算字段长度和偏移量的需要,这可能会消耗大量 gas。通过提供现成可用的信息,存储可以更有效地执行存储操作,而从存储读取的组件可以从 Tables 表中检索它以解码相应的记录。

特殊的 Tables

包含一个特殊的 Tables 表为链下索引器提供了显着的好处。虽然为表格注册发出事件对于在原始编码数据上运行的基本索引器来说并非绝对必要,但这样做使索引器能够意识到每个表格使用的模式。这种意识使得能够开发更高级的、模式感知的索引器 API(例如,类似 SQL 的查询功能),从而增强了链下数据交互的实用性和灵活性。

通过为表格注册重用现有的 Store 抽象,我们还可以简化实现并消除对其他特定表格注册事件的需求。索引器可以利用标准的 Store 事件来访问模式信息,从而确保一致性并降低复杂性。

参考实现

存储事件索引

以下示例显示了一个简单的内存索引器如何使用 Store 事件来复制链下的 Store 状态。重要的是要注意,此索引器对原始编码数据进行操作,其本身并不是很有用,但可以改进,我们将在下一节中解释。

我们在此示例中使用 TypeScript,但可以使用其他语言轻松地复制它。

type Hex = `0x${string}`;

type Record = {
  staticData: Hex;
  encodedLengths: Hex;
  dynamicData: Hex;
};

const store = new Map<string, Record>();

// Create a key string from a table ID and key tuple to use in our store Map above
// 从表 ID 和键元组创建一个键字符串,以便在我们上面的 store Map 中使用
function storeKey(tableId: Hex, keyTuple: Hex[]): string {
  return `${tableId}:${keyTuple.join(",")}`;
}

// Like `Array.splice`, but for strings of bytes
// 类似于 `Array.splice`,但用于字节字符串
function bytesSplice(
  data: Hex,
  start: number,
  deleteCount = 0,
  newData: Hex = "0x"
): Hex {
  const dataNibbles = data.replace(/^0x/, "").split("");
  const newDataNibbles = newData.replace(/^0x/, "").split("");
  return `0x${dataNibbles
    .splice(start, deleteCount * 2)
    .concat(newDataNibbles)
    .join("")}`;
}

function bytesLength(data: Hex): number {
  return data.replace(/^0x/, "").length / 2;
}

function processStoreEvent(log: StoreEvent) {
  if (log.eventName === "Store_SetRecord") {
    const key = storeKey(log.args.tableId, log.args.keyTuple);

    // Overwrite all of the Record's fields
    // 覆盖 Record 的所有字段
    store.set(key, {
      staticData: log.args.staticData,
      encodedLengths: log.args.encodedLengths,
      dynamicData: log.args.dynamicData,
    });
  } else if (log.eventName === "Store_SpliceStaticData") {
    const key = storeKey(log.args.tableId, log.args.keyTuple);
    const record = store.get(key) ?? {
      staticData: "0x",
      encodedLengths: "0x",
      dynamicData: "0x",
    };

    // Splice the static field data of the Record
    // 拼接 Record 的静态字段数据
    store.set(key, {
      staticData: bytesSplice(
        record.staticData,
        log.args.start,
        bytesLength(log.args.data),
        log.args.data
      ),
      encodedLengths: record.encodedLengths,
      dynamicData: record.dynamicData,
    });
  } else if (log.eventName === "Store_SpliceDynamicData") {
    const key = storeKey(log.args.tableId, log.args.keyTuple);
    const record = store.get(key) ?? {
      staticData: "0x",
      encodedLengths: "0x",
      dynamicData: "0x",
    };

    // Splice the dynamic field data of the Record
    // 拼接 Record 的动态字段数据
    store.set(key, {
      staticData: record.staticData,
      encodedLengths: log.args.encodedLengths,
      dynamicData: bytesSplice(
        record.dynamicData,
        log.args.start,
        log.args.deleteCount,
        log.args.data
      ),
    });
  } else if (log.eventName === "Store_DeleteRecord") {
    const key = storeKey(log.args.tableId, log.args.keyTuple);

    // Delete the whole Record
    // 删除整个 Record
    store.delete(key);
  }
}

安全考虑

访问控制

本标准仅定义从 Store 读取数据的函数(getRecordgetFieldgetFieldLength)。用于在存储中设置或修改记录的方法留给每个特定的实现。因此,实现必须提供适当的访问控制机制来写入存储,并根据其特定用例进行定制。

链上数据可访问性

存储在存储中的所有数据不仅可以通过链下访问,而且还可以通过提供的读取函数(getRecordgetFieldgetFieldLength通过链上被其他智能合约访问。这与智能合约的典型行为不同,在智能合约中,内部存储变量默认是私有的,除非提供显式的 getter 函数,否则其他合约无法直接读取。因此,开发人员必须注意,存储在存储中的任何数据都可以被其他智能合约公开访问。

版权

通过 CC0 放弃版权及相关权利。

Citation

Please cite this document as:

alvarius (@alvrs), dk1a (@dk1a), frolic (@frolic), ludens (@ludns), vdrg (@vdrg), yonada <yonada@proton.me>, "ERC-7813: 存储,基于表格的可内省存储 [DRAFT]," Ethereum Improvement Proposals, no. 7813, November 2024. [Online serial]. Available: https://eips.ethereum.org/EIPS/eip-7813.