Skip to content
On this page

L1-16: Subgraphs(子图索引)

1. 问题

使用 The Graph 协议为以太坊智能合约构建高效的链上数据查询层。通过定义数据源(subgraph.yaml)、数据模型(schema.graphql)和事件处理器(mapping.ts),将链上事件转化为可快速查询的 GraphQL API。

核心挑战:ERC-1155 GameItems 合约的 TransferSingle、TransferBatch 和 TokenCreated 事件需要被索引为 Token、Holder、Transfer 三个 GraphQL 实体。

2. 原因

链上数据存储在 EVM 状态树中,按事件遍历极其低效——前端如果直接逐区块扫描事件来构建用户界面,对中等规模的 dApp 就会变得不可用。The Graph 的 Subgraph 模式通过预索引解决了这个问题:

  • 预计算:所有事件在发生时就转换为关系型实体
  • 毫秒级查询:GraphQL 查询直接命中索引过的数据库
  • 标准化:每个 dApp 使用相同的模式,前端开发者无需了解链上存储细节
  • 可组合:多个 subgraph 的数据可以通过 GraphQL 联合查询

这是每个 dApp 前端的标准数据层。

3. 方案

架构设计

subgraph.yaml        → 定义数据源(合约地址 + ABI + 起始块)
schema.graphql       → 定义查询实体(GraphQL 类型)
mapping.ts           → 事件处理器(将链上事件转化为实体)

Step 1: subgraph.yaml

yaml
specVersion: 1.0.0
schema:
  file: ./schema.graphql
dataSources:
  - kind: ethereum/contract
    name: GameItems
    network: sepolia
    source:
      address: "0xYourContractAddress"
      abi: GameItems
      startBlock: 5000000
    mapping:
      kind: ethereum/events
      apiVersion: 0.0.7
      language: wasm/assemblyscript
      entities:
        - Token
        - Transfer
        - Holder
      abis:
        - name: GameItems
          file: ./abis/GameItems.json
      eventHandlers:
        - event: TransferSingle(indexed address,indexed address,indexed address,uint256,uint256)
          handler: handleTransferSingle
        - event: TransferBatch(indexed address,indexed address,indexed address,uint256[],uint256[])
          handler: handleTransferBatch
        - event: TokenCreated(indexed uint256,string,uint256)
          handler: handleTokenCreated
      file: ./src/mapping.ts

Step 2: schema.graphql

graphql
type Token @entity {
  id: ID!
  uri: String!
  totalSupply: BigInt!
  holders: [Holder!]! @derivedFrom(field: "token")
  transfers: [Transfer!]! @derivedFrom(field: "token")
}

type Holder @entity {
  id: ID!           # tokenId-address
  address: Bytes!
  token: Token!
  balance: BigInt!
}

type Transfer @entity {
  id: ID!           # txHash-logIndex
  from: Bytes!
  to: Bytes!
  token: Token!
  amount: BigInt!
  timestamp: BigInt!
  blockNumber: BigInt!
  transactionHash: Bytes!
}

Step 3: mapping.ts 核心逻辑

typescript
export function handleTransferSingle(event: TransferSingle): void {
  let tokenId = event.params.id.toString();
  let token = Token.load(tokenId);
  if (token == null) return;

  // 更新发送者
  if (event.params.from != Bytes.empty()) {
    let fromHolderId = tokenId + '-' + event.params.from.toHexString();
    let fromHolder = Holder.load(fromHolderId);
    if (fromHolder != null) {
      fromHolder.balance = fromHolder.balance.minus(event.params.amount);
      fromHolder.save();
    }
  }

  // 更新接收者
  let toHolderId = tokenId + '-' + event.params.to.toHexString();
  let toHolder = Holder.load(toHolderId);
  if (toHolder == null) {
    toHolder = new Holder(toHolderId);
    toHolder.address = event.params.to;
    toHolder.token = tokenId;
    toHolder.balance = BigInt.zero();
  }
  toHolder.balance = toHolder.balance.plus(event.params.amount);
  toHolder.save();

  // 创建 Transfer 记录
  let transferId = event.transaction.hash.toHexString() + '-' + event.logIndex.toString();
  let transfer = new Transfer(transferId);
  transfer.from = event.params.from;
  transfer.to = event.params.to;
  transfer.token = tokenId;
  transfer.amount = event.params.amount;
  transfer.timestamp = event.block.timestamp;
  transfer.blockNumber = event.block.number;
  transfer.transactionHash = event.transaction.hash;
  transfer.save();
}

4. 遭遇的陷阱

4.1 AssemblyScript 与 TypeScript 的差异

The Graph 的 mapping 使用 AssemblyScript(类似 TypeScript 但运行在 WASM 中),不支持:switch 语句(某些情况下)、for...of 循环、async/awaittry/catch、正则表达式。TypeScript 的 number 被映射为 i32/f64(有大小限制),大数值必须用 BigInt

4.2 startBlock 的选择

如果 startBlock 设置太晚,会错过合约早期的历史事件。如果设置太早(如从创世块开始),索引器需要扫描数百万个空块,浪费时间和资源。

4.3 实体 ID 的唯一性

每个 entity 必须有唯一的 id。对于 Transfer 实体,常用的 ID 模式是 txHash-logIndex。但如果同一交易中同一 logIndex 被处理两次(由于 Batch 事件),需要确保 ID 不会冲突。

4.4 合约升级导致 ABI 变化

如果合约通过代理模式升级,增加新的事件或修改事件签名,subgraph 需要更新 ABI 和事件处理器。否则新事件会被忽略,导致索引数据不完整。

5. 陷阱的原因

5.1

AssemblyScript 是 TypeScript 的严格子集(编译为 WASM),代码运行在 The Graph 的 WASM 运行时中,而非 Node.js 或浏览器环境。缺少常见的 JavaScript 功能是因为 WASM 不支持某些 JS 运行时特性。

5.2

The Graph 节点从 startBlock 开始逐一处理区块,对每个区块调用合约的 eth_getLogs。过早的 startBlock 意味着数千个空 RPC 调用。

6. 如何解决陷阱

  • 仔细阅读 The Graph 的 AssemblyScript API 文档(特别是 BigIntBytesAddress 类型)
  • startBlock 设为合约部署区块号(可通过 etherscan 查询)
  • 实体 ID 使用组合键:tokenId + '-' + addresstxHash + '-' + logIndex
  • 升级合约后,在 subgraph.yaml 中添加新的 dataSource 或更新 ABI

7. 技术要点

要点说明
Subgraph 三要素subgraph.yaml + schema.graphql + mapping.ts
AssemblyScriptWASM 编译的 TypeScript 严格子集
实体模型@entity 标记,@derivedFrom 定义反向关系
BigInt 类型所有以太坊数值使用 BigInt(非 number)
startBlock设为合约部署区块号,避免扫描空块
GraphQL 查询前端通过 graphql(client, query) 获取索引数据

Built with AiAda